diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx
index 5c9dbfa26..b9e698a40 100644
--- a/docs/concepts/compositions.mdx
+++ b/docs/concepts/compositions.mdx
@@ -129,52 +129,62 @@ Every composition has two layers:
HyperFrames does not automatically bind `data-var-*` attributes into your composition DOM or CSS.
-Today, the supported pattern is:
+The supported pattern is:
-1. Pass per-instance values on the composition host with `data-variable-values`
-2. Read those values inside the composition and apply them in your own script
+1. Declare the variables once on the sub-comp's `` root with `data-composition-variables` (id + type + default).
+2. Pass per-instance values on each composition host with `data-variable-values`.
+3. Read the resolved values inside the composition with `window.__hyperframes.getVariables()`. The runtime layers the host's `data-variable-values` over the declared defaults on a per-instance basis, so the same source can be embedded multiple times with different values.
```html index.html
+
```
```html compositions/card.html
-
-
-
Fallback
+
+
+
+
-
+ [data-composition-id="card"] .title {
+ color: var(--card-color);
+ }
+
-
-
-
+
+
+
+
```
-If you are building tooling on top of `@hyperframes/core`, you can also declare variable metadata separately with `data-composition-variables` and read it via `extractCompositionMetadata()`. That metadata is descriptive only; you still apply the actual values manually inside the composition.
+If you are building tooling on top of `@hyperframes/core`, the same `data-composition-variables` array is readable via `extractCompositionMetadata()` for Studio editing UI and analysis pipelines.
## Listing Compositions
diff --git a/docs/concepts/data-attributes.mdx b/docs/concepts/data-attributes.mdx
index 064b55aed..ed1caaff1 100644
--- a/docs/concepts/data-attributes.mdx
+++ b/docs/concepts/data-attributes.mdx
@@ -29,8 +29,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `data-width` | `"1920"` | Composition width in pixels |
| `data-height` | `"1080"` | Composition height in pixels |
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
-| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. HyperFrames carries these values through, but your composition script must read and apply them manually. |
-| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables ''` overrides these defaults at render time. |
+| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). |
+| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables ''` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. |
## Element Visibility
diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 95110af8c..1c01100c0 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -51,6 +51,76 @@ body { margin: 0; }
expect(scoped).not.toContain('[data-start="0"]');
});
+ it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => {
+ const { document } = parseHTML(``);
+ const fakeWindow: Record = {
+ document,
+ __timelines: {},
+ __hfVariablesByComp: {
+ "card-1": { title: "Pro", price: "$29" },
+ "card-2": { title: "Enterprise", price: "Custom" },
+ },
+ __hyperframes: {
+ getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
+ fitTextFontSize: () => undefined,
+ },
+ __captured: undefined as unknown,
+ };
+ const wrapped = wrapScopedCompositionScript(
+ `window.__captured = __hyperframes.getVariables();`,
+ "card-1",
+ );
+
+ new Function("window", wrapped)(fakeWindow);
+
+ expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
+ });
+
+ it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => {
+ const { document } = parseHTML(``);
+ const fakeWindow: Record = {
+ document,
+ __timelines: {},
+ __hyperframes: {
+ getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
+ fitTextFontSize: () => undefined,
+ },
+ __captured: undefined as unknown,
+ };
+ const wrapped = wrapScopedCompositionScript(
+ `window.__captured = __hyperframes.getVariables();`,
+ "missing",
+ );
+
+ new Function("window", wrapped)(fakeWindow);
+
+ expect(fakeWindow.__captured).toEqual({});
+ });
+
+ it("scoped getVariables returns a fresh object — mutations don't leak into the shared table", () => {
+ const { document } = parseHTML(``);
+ const variablesByComp: Record> = {
+ "card-1": { title: "Pro" },
+ };
+ const fakeWindow: Record = {
+ document,
+ __timelines: {},
+ __hfVariablesByComp: variablesByComp,
+ __hyperframes: {
+ getVariables: () => ({}),
+ fitTextFontSize: () => undefined,
+ },
+ };
+ const wrapped = wrapScopedCompositionScript(
+ `var v = __hyperframes.getVariables(); v.title = "MUTATED"; v.added = "extra";`,
+ "card-1",
+ );
+
+ new Function("window", wrapped)(fakeWindow);
+
+ expect(variablesByComp["card-1"]).toEqual({ title: "Pro" });
+ });
+
it("executes document and GSAP selectors inside the composition root", () => {
const { document } = parseHTML(`
Scene
diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts
index 34af7ba26..ea3c55953 100644
--- a/packages/core/src/compiler/compositionScoping.ts
+++ b/packages/core/src/compiler/compositionScoping.ts
@@ -261,11 +261,21 @@ export function wrapScopedCompositionScript(
return typeof value === "function" ? value.bind(target) : value;
},
});
+ var __hfBaseHyperframes = window.__hyperframes;
+ var __hfScopedHyperframes = !__hfBaseHyperframes
+ ? __hfBaseHyperframes
+ : Object.assign({}, __hfBaseHyperframes, {
+ getVariables: function() {
+ var byComp = window.__hfVariablesByComp;
+ var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null;
+ return scoped ? Object.assign({}, scoped) : {};
+ },
+ });
var __hfRun = function() {
try {
- (function(document, gsap, window) {
+ (function(document, gsap, window, __hyperframes) {
${source}
- }).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
+ }).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts
index 6bcc2bcb0..f5ce2312c 100644
--- a/packages/core/src/runtime/compositionLoader.test.ts
+++ b/packages/core/src/runtime/compositionLoader.test.ts
@@ -264,6 +264,144 @@ describe("loadExternalCompositions", () => {
expect(host1.querySelector("p")?.textContent).toBe("A");
expect(host2.querySelector("p")?.textContent).toBe("B");
});
+
+ describe("variable scoping (window.__hfVariablesByComp)", () => {
+ type WindowWithScopedVars = Window & {
+ __hfVariablesByComp?: Record>;
+ };
+
+ afterEach(() => {
+ delete (window as WindowWithScopedVars).__hfVariablesByComp;
+ });
+
+ it("merges sub-comp declared defaults with host data-variable-values", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-src", "https://example.com/card.html");
+ host.setAttribute("data-composition-id", "card-1");
+ host.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
+ document.body.appendChild(host);
+
+ const compositionHtml = `
+
+
+
+
+
+ `;
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(compositionHtml, { status: 200 }),
+ );
+
+ await loadExternalCompositions({ ...defaultParams });
+
+ const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
+ expect(byComp["card-1"]).toEqual({
+ title: "Pro", // host wins over declared default
+ price: "$29", // host wins
+ theme: "light", // host omits → declared default falls through
+ });
+ });
+
+ it("uses declared defaults when host has no data-variable-values", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-src", "https://example.com/card.html");
+ host.setAttribute("data-composition-id", "card-2");
+ document.body.appendChild(host);
+
+ const compositionHtml = `
+
+
+
+ `;
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(compositionHtml, { status: 200 }),
+ );
+
+ await loadExternalCompositions({ ...defaultParams });
+
+ const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
+ expect(byComp["card-2"]).toEqual({ title: "Default Title" });
+ });
+
+ it("skips registration when neither declared defaults nor host overrides exist", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-src", "https://example.com/card.html");
+ host.setAttribute("data-composition-id", "card-empty");
+ document.body.appendChild(host);
+
+ const compositionHtml = `
+
+ `;
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(compositionHtml, { status: 200 }),
+ );
+
+ await loadExternalCompositions({ ...defaultParams });
+
+ const byComp = (window as WindowWithScopedVars).__hfVariablesByComp;
+ expect(byComp?.["card-empty"]).toBeUndefined();
+ });
+
+ it("ignores invalid JSON in host data-variable-values", async () => {
+ const host = document.createElement("div");
+ host.setAttribute("data-composition-src", "https://example.com/card.html");
+ host.setAttribute("data-composition-id", "card-bad");
+ host.setAttribute("data-variable-values", "{not json");
+ document.body.appendChild(host);
+
+ const compositionHtml = `
+
+
+
+ `;
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(compositionHtml, { status: 200 }),
+ );
+
+ await loadExternalCompositions({ ...defaultParams });
+
+ const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
+ expect(byComp["card-bad"]).toEqual({ title: "OK" });
+ });
+
+ it("registers per-instance entries for multiple sub-comps with the same source", async () => {
+ const host1 = document.createElement("div");
+ host1.setAttribute("data-composition-src", "https://example.com/card.html");
+ host1.setAttribute("data-composition-id", "card-A");
+ host1.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
+ document.body.appendChild(host1);
+
+ const host2 = document.createElement("div");
+ host2.setAttribute("data-composition-src", "https://example.com/card.html");
+ host2.setAttribute("data-composition-id", "card-B");
+ host2.setAttribute("data-variable-values", '{"title":"Enterprise","price":"Custom"}');
+ document.body.appendChild(host2);
+
+ const compositionHtml = `
+
+
+
+ `;
+ vi.spyOn(globalThis, "fetch").mockImplementation(
+ async () => new Response(compositionHtml, { status: 200 }),
+ );
+
+ await loadExternalCompositions({ ...defaultParams });
+
+ const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
+ expect(byComp["card-A"]).toEqual({ title: "Pro", price: "$29" });
+ expect(byComp["card-B"]).toEqual({ title: "Enterprise", price: "Custom" });
+ });
+ });
});
describe("loadInlineTemplateCompositions", () => {
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index df484f108..6c87119a7 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -1,4 +1,5 @@
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
+import { readDeclaredDefaults } from "./getVariables";
type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[];
@@ -73,6 +74,19 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
}
}
+function parseHostVariableValues(host: Element): Record {
+ const raw = host.getAttribute("data-variable-values");
+ if (!raw) return {};
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return {};
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
+ return parsed as Record;
+}
+
async function mountCompositionContent(params: {
host: Element;
hostCompositionId: string | null;
@@ -88,6 +102,15 @@ async function mountCompositionContent(params: {
headStyles?: HTMLStyleElement[];
/** Extra