diff --git a/README.md b/README.md
index b956c35..fd62e26 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,10 @@ See [Library](#library) for the full docs on each. Try it in the [Svelte REPL](h
| Package version | Svelte version | Notes |
| :--------------- | :----------------- | :----------------------------------------- |
-| [1.x](https://github.com/metonym/svelte-intersection-observer/tree/v1.2.x) | 3, 4, 5 (non-runes) | Uses `export let`, slots, and `on:` events |
-| 2.x | ≥5.29 (runes mode only) | Uses `$props()`, snippets, and callback props |
+| [1.x](https://github.com/metonym/svelte-intersection-observer/tree/v1.2.x) | 3, 4, 5 (legacy/non-runes) | Uses `export let`, slots, and `on:` events |
+| 2.x | ≥5.29 (legacy + runes) | Uses `$props()`, snippets, and callback props |
+
+All primitives are implemented with runes internally, but Svelte 5 lets a non-runes ("legacy") component consume them — `bind:`, callback props, snippets, and `{@attach}` all interoperate across that boundary. The one exception is [`createIntersectionObserver`](#createintersectionobserver): it exposes `intersecting`/`entry` as plain getters with no bind:/callback prop, and a legacy-mode template doesn't reactively track getter reads, so its state won't update the UI unless the consuming component is itself in runes mode.
@@ -362,6 +364,8 @@ To get intersection state without wrapping markup in a component, use `createInt
`createIntersectionObserver` takes the same options as [`intersectAttachment`](#intersectattachment) (as a function returning the options object) and reuses its underlying observer logic.
+**Note**: the returned `intersecting`/`entry` are plain getters backed by runes. They only stay reactive when read from a runes-mode component — a non-runes ("legacy") consumer won't re-render when they change, since its template doesn't track getter reads. If you need this to work from a legacy component, use one of the other primitives (e.g. [`intersectAttachment`](#intersectattachment) with its `onobserve` callback) instead.
+
#### Return value
| Name | Description | Type |
diff --git a/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte b/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte
new file mode 100644
index 0000000..dee415a
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
+ {intersecting ? "Element is in view" : "Element is not in view"}
+
+
Exit count: {exitCount}
+
+ (intersecting = e.detail.isIntersecting)}
+ onexit={() => (exitCount += 1)}
+ data-testid="target"
+>
+ Hello world
+
diff --git a/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte b/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte
new file mode 100644
index 0000000..ba68298
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+
+ {intersecting ? "Element is in view" : "Element is not in view"}
+
+Exit count: {exitCount}
+
+ (intersecting = e.detail.isIntersecting)}
+ onexit={() => (exitCount += 1)}
+ data-testid="target"
+>
+ Hello world
+
diff --git a/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte b/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte
new file mode 100644
index 0000000..1e7c0a9
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ Hello world
+
diff --git a/tests/e2e/fixtures/LegacyConsumerFixture.svelte b/tests/e2e/fixtures/LegacyConsumerFixture.svelte
new file mode 100644
index 0000000..92eaceb
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyConsumerFixture.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ (exitCount += 1)}
+>
+
+ Exit count: {exitCount}
+
+
diff --git a/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte b/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte
new file mode 100644
index 0000000..bba0f28
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+{#each ids as id (id)}
+ {
+ visible = { ...visible, [id]: entry.isIntersecting };
+ },
+ })}
+ >
+ Item {id}
+
+{/each}
diff --git a/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte b/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte
new file mode 100644
index 0000000..af92c3d
--- /dev/null
+++ b/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
+ {#snippet children({ elementIntersections })}
+
+
+ Item 1: {elementIntersections.get(ref1) ? "visible" : "not visible"}
+
+
+ Item 2: {elementIntersections.get(ref2) ? "visible" : "not visible"}
+
+
+
+
+ Item 1
+
+
+ Item 2
+
+ {/snippet}
+
diff --git a/tests/unit/legacy-consumer.test.ts b/tests/unit/legacy-consumer.test.ts
new file mode 100644
index 0000000..705882c
--- /dev/null
+++ b/tests/unit/legacy-consumer.test.ts
@@ -0,0 +1,169 @@
+import { flushSync } from "svelte";
+import { afterEach, describe, expect, test } from "vitest";
+import LegacyActionConsumerFixture from "../e2e/fixtures/LegacyActionConsumerFixture.svelte";
+import LegacyAttachmentConsumerFixture from "../e2e/fixtures/LegacyAttachmentConsumerFixture.svelte";
+import LegacyComposableConsumerFixture from "../e2e/fixtures/LegacyComposableConsumerFixture.svelte";
+import LegacyConsumerFixture from "../e2e/fixtures/LegacyConsumerFixture.svelte";
+import LegacyGroupConsumerFixture from "../e2e/fixtures/LegacyGroupConsumerFixture.svelte";
+import LegacyMultipleConsumerFixture from "../e2e/fixtures/LegacyMultipleConsumerFixture.svelte";
+import { MockIntersectionObserver } from "./mock-intersection-observer";
+import { render } from "./render";
+
+// Every primitive here is implemented with runes ($props, $bindable, $state),
+// but Svelte 5 lets a non-runes ("legacy", ``)
+// parent consume them. Each fixture below locks in `runes={false}` (so it
+// can't silently drift into runes mode) and exercises one primitive from
+// that legacy parent, confirming bind:/callback props/`{@attach}`/snippets
+// and legacy `$:` reactivity all interoperate correctly across the boundary.
+
+let cleanup: (() => void) | undefined;
+
+afterEach(() => {
+ cleanup?.();
+ cleanup = undefined;
+});
+
+describe("legacy (non-runes) consumer", () => {
+ test("IntersectionObserver: bind:intersecting updates a legacy $: derived value and onexit fires", () => {
+ const rendered = render(LegacyConsumerFixture);
+ cleanup = rendered.cleanup;
+ const header = rendered.target.querySelector("header");
+ const el = rendered.target.querySelector('[data-testid="exit-count"]');
+ if (!header || !el) throw new Error("fixture markup missing");
+
+ expect(header.textContent).toContain("Element is not in view");
+
+ const observer = MockIntersectionObserver.last();
+ expect(observer.observedElements.has(el)).toBe(true);
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
+ expect(header.textContent).toContain("Element is in view");
+ expect(el.textContent).toContain("Exit count: 0");
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
+ expect(header.textContent).toContain("Element is not in view");
+ expect(el.textContent).toContain("Exit count: 1");
+ });
+
+ test("MultipleIntersectionObserver: snippet params reflect per-element state via a legacy $: elements array", () => {
+ const rendered = render(LegacyMultipleConsumerFixture);
+ cleanup = rendered.cleanup;
+ const item1 = rendered.target.querySelector('[data-testid="item-1"]');
+ const item2 = rendered.target.querySelector('[data-testid="item-2"]');
+ if (!item1 || !item2) throw new Error("fixture markup missing");
+
+ const observer = MockIntersectionObserver.last();
+ expect(observer.observedElements.has(item1)).toBe(true);
+ expect(observer.observedElements.has(item2)).toBe(true);
+
+ flushSync(() =>
+ observer.trigger([{ target: item1, isIntersecting: true }]),
+ );
+
+ expect(
+ rendered.target.querySelector('[data-testid="item-1-status"]')
+ ?.textContent,
+ ).toContain("Item 1: visible");
+ expect(
+ rendered.target.querySelector('[data-testid="item-2-status"]')
+ ?.textContent,
+ ).toContain("Item 2: not visible");
+ });
+
+ test("intersect action: use:intersect updates legacy state via onobserve/onexit callback props", () => {
+ const rendered = render(LegacyActionConsumerFixture);
+ cleanup = rendered.cleanup;
+ const header = rendered.target.querySelector("header");
+ const el = rendered.target.querySelector('[data-testid="target"]');
+ if (!header || !el) throw new Error("fixture markup missing");
+
+ expect(header.textContent).toContain("Element is not in view");
+
+ const observer = MockIntersectionObserver.last();
+ expect(observer.observedElements.has(el)).toBe(true);
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
+ expect(header.textContent).toContain("Element is in view");
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
+ expect(header.textContent).toContain("Element is not in view");
+ expect(
+ rendered.target.querySelector('[data-testid="exit-count"]')?.textContent,
+ ).toContain("Exit count: 1");
+ });
+
+ test("intersectAttachment: {@attach} updates legacy state via onobserve/onexit callback props", () => {
+ const rendered = render(LegacyAttachmentConsumerFixture);
+ cleanup = rendered.cleanup;
+ const header = rendered.target.querySelector("header");
+ const el = rendered.target.querySelector('[data-testid="target"]');
+ if (!header || !el) throw new Error("fixture markup missing");
+
+ expect(header.textContent).toContain("Element is not in view");
+
+ const observer = MockIntersectionObserver.last();
+ expect(observer.observedElements.has(el)).toBe(true);
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
+ expect(header.textContent).toContain("Element is in view");
+
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
+ expect(header.textContent).toContain("Element is not in view");
+ expect(
+ rendered.target.querySelector('[data-testid="exit-count"]')?.textContent,
+ ).toContain("Exit count: 1");
+ });
+
+ // Unlike the other primitives, `createIntersectionObserver` exposes state
+ // via plain getters (no bind:/callback prop) — a legacy-mode consumer reads
+ // those getters directly in the template. Svelte's legacy compiler wraps
+ // such reads in `untrack()` (dependencies are inferred syntactically from
+ // reassigned local `let`s, not from external reactive getters), so the
+ // getter's *value* is correct at every read, but the template does not
+ // re-render when it changes underneath a legacy parent.
+ test("createIntersectionObserver: side effects (unobserve on `once`) still run, but the legacy template does not re-render off the getter", () => {
+ const rendered = render(LegacyComposableConsumerFixture);
+ cleanup = rendered.cleanup;
+ const header = rendered.target.querySelector("header");
+ const el = rendered.target.querySelector('[data-testid="target"]');
+ if (!header || !el) throw new Error("fixture markup missing");
+
+ expect(header.textContent).toContain("Element is not in view");
+
+ const observer = MockIntersectionObserver.last();
+ flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
+
+ // `once: true` still unobserves — that's driven by the composable's own
+ // internal event listener, not by legacy template reactivity.
+ expect(observer.observedElements.has(el)).toBe(false);
+
+ // But the legacy `` text never updates to reflect it.
+ expect(header.textContent).toContain("Element is not in view");
+ });
+
+ test("createIntersectionGroup: one shared observer drives independent legacy state per element", () => {
+ const rendered = render(LegacyGroupConsumerFixture);
+ cleanup = rendered.cleanup;
+ const item1 = rendered.target.querySelector('[data-testid="item-1"]');
+ const item2 = rendered.target.querySelector('[data-testid="item-2"]');
+ if (!item1 || !item2) throw new Error("fixture markup missing");
+
+ expect(MockIntersectionObserver.instances).toHaveLength(1);
+ const observer = MockIntersectionObserver.last();
+ expect(observer.observedElements.has(item1)).toBe(true);
+ expect(observer.observedElements.has(item2)).toBe(true);
+
+ flushSync(() =>
+ observer.trigger([{ target: item1, isIntersecting: true }]),
+ );
+
+ expect(
+ rendered.target.querySelector('[data-testid="item-1-status"]')
+ ?.textContent,
+ ).toContain("Item 1 is visible");
+ expect(
+ rendered.target.querySelector('[data-testid="item-2-status"]')
+ ?.textContent,
+ ).toContain("Item 2 is not visible");
+ });
+});