Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [2.2.6] - 2026-07-08

### Added

#### Variables and Presets Support

- **New `variables` prop**: Pass authored variable values (colors, numbers, booleans, strings, Vec2/Vec3, texture URLs) declaratively
- Applied as `initialVariables` when the scene is created
- Synced to the live scene via `setVariables()` when values change, without re-initializing the scene
- **New `preset` prop**: Apply an authored preset by ID or name
- Applied as `initialPreset` on load and synced via `setPreset()` when it changes
- **New `onVariableChange` prop**: Callback fired whenever a scene variable changes
- **Typed scene API**: `UnicornStudioScene` (received through `sceneRef`) now includes the full SDK 2.2 runtime surface — `setVariable()`, `setVariables()`, `getVariable()`, `getVariables()`, `getVariableDefinition(s)()`, `getVariableManifest()`, `onVariableChange()`, `getPresets()`, `getPreset()`, `setPreset()`, `setProp()`, `setTexture()`, `getLayers()`, `getLayer()`, and `getMouse()`
- New exported types: `UnicornVariables`, `UnicornVariableValue`, `UnicornVariableChangeCallback`, `UnicornVariableDefinition`, `UnicornVariableManifestEntry`, `UnicornPreset`, `UnicornVec2`, `UnicornVec3`

### Changed

- Updated the Unicorn Studio SDK version to `2.2.6`

## [2.1.12] - 2026-05-05

### Changed
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,64 @@ export default function MyComponent() {

The `sceneRef` value is assigned once the scene finishes loading and is cleared automatically if the scene is destroyed or re-initialized.

## Variables and Presets

Scenes published with Unicorn Studio SDK 2.2+ can expose authored **variables** (brand colors, effect intensity, image URLs, etc.) and **presets** (named groups of variable values, such as themes). Both are supported declaratively:

```tsx
<UnicornScene
projectId="YOUR_PROJECT_EMBED_ID"
preset="Dark Theme"
variables={{ brandColor: "#7c3aed", intensity: 0.65 }}
onVariableChange={(name, value, values) => {
console.log("Variable changed:", name, value, values);
}}
/>
```

- `variables` is applied as `initialVariables` when the scene is created, then synced to the live scene via `setVariables()` whenever the values change — the scene is **not** re-initialized. Unknown names or invalid values are ignored by the SDK with a console warning.
- `preset` accepts an authored preset ID or name. It is applied as `initialPreset` on load (before `variables`, so explicit variable values win) and synced via `setPreset()` when it changes.
- `onVariableChange` fires for every variable change, including changes made through the `variables` prop, presets, and imperative `setVariable()` calls.

### Theming Example

```tsx
export default function Hero({ theme }: { theme: "light" | "dark" }) {
return (
<UnicornScene
projectId="YOUR_PROJECT_EMBED_ID"
preset={theme === "dark" ? "Dark Theme" : "Light Theme"}
/>
);
}
```

### Imperative Access

For advanced use cases (animating variables with GSAP, building custom control panels from the variable manifest, direct layer control), use `sceneRef` to reach the full scene API:

```tsx
const sceneRef = useRef<UnicornStudioScene | null>(null);

const inspect = () => {
console.table(sceneRef.current?.getVariableManifest?.());
console.log(sceneRef.current?.getPresets?.());
};

const animate = () => {
const scene = sceneRef.current;
if (!scene) return;
const proxy = { amount: Number(scene.getVariable?.("amount") ?? 0) };
gsap.to(proxy, {
amount: 1,
duration: 1.2,
onUpdate: () => scene.setVariable?.("amount", proxy.amount),
});
};
```

The scene instance is fully typed, including `setVariable()`, `getVariableManifest()`, `setPreset()`, `setProp()`, `setTexture()`, `getLayers()`, and `getMouse()`. See the [Unicorn Studio runtime control guide](https://www.unicorn.studio/unicornstudio-llms.txt) for the complete API.

## Placeholder Support

The component now supports flexible placeholder options that can be displayed while loading, on error, or when WebGL is not supported.
Expand Down Expand Up @@ -273,6 +331,9 @@ The component now supports flexible placeholder options that can be displayed wh
| `lazyLoad` | `boolean` | `true` | Load scene only when scrolled into view |
| `production` | `boolean` | `true` | Use production mode when initializing the scene |
| `paused` | `boolean` | `false` | Pause or resume the scene animation |
| `variables` | `UnicornVariables` | - | Authored variable values, synced to the live scene when they change |
| `preset` | `string` | - | Authored preset ID or name, synced to the live scene when it changes |
| `onVariableChange` | `(name, value, values) => void` | - | Callback fired whenever a scene variable changes |
| `placeholder` | `string \| ReactNode` | - | Placeholder content (image URL or React component) |
| `placeholderClassName` | `string` | - | CSS classes for placeholder div (when using CSS placeholder) |
| `showPlaceholderOnError` | `boolean` | `true` | Show placeholder when scene fails to load |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unicornstudio-react",
"version": "2.1.12-1",
"version": "2.2.6",
"description": "React component for embedding Unicorn.Studio interactive scenes with TypeScript support. Compatible with React (Vite) and Next.js.",
"keywords": [
"react",
Expand Down
206 changes: 206 additions & 0 deletions src/__tests__/useUnicornScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,212 @@
expect(scene.paused).toBe(false);
});

// -----------------------------------------------------------------------
// Variables and presets
// -----------------------------------------------------------------------

it("passes variables and preset to addScene as initial values", async () => {
const scene = createMockScene();
addSceneMock.mockResolvedValueOnce(scene);

renderHook(() =>
useUnicornScene({
...defaultProps(elementRef),
variables: { brandColor: "#7c3aed", intensity: 0.65 },
preset: "Dark Theme",
}),
);

await act(async () => {});

const config = addSceneMock.mock.calls[0][0];
expect(config.initialVariables).toEqual({
brandColor: "#7c3aed",
intensity: 0.65,
});
expect(config.initialPreset).toBe("Dark Theme");
});

it("syncs variable changes via setVariables without re-initializing", async () => {
const setVariables = vi.fn();
const scene = createMockScene({ setVariables });
addSceneMock.mockResolvedValue(scene);

const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
variables: { intensity: 0.2 },
},
});

await act(async () => {});
expect(addSceneMock).toHaveBeenCalledTimes(1);
// Initial values are delivered via initialVariables, not setVariables
expect(setVariables).not.toHaveBeenCalled();

rerender({ ...defaultProps(elementRef), variables: { intensity: 0.9 } });
await act(async () => {});

expect(setVariables).toHaveBeenCalledWith({ intensity: 0.9 });
expect(addSceneMock).toHaveBeenCalledTimes(1);
});

it("does not call setVariables when a new object has identical values", async () => {
const setVariables = vi.fn();
const scene = createMockScene({ setVariables });
addSceneMock.mockResolvedValue(scene);

const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
variables: { intensity: 0.5 },
},
});

await act(async () => {});

// New object reference, same values
rerender({ ...defaultProps(elementRef), variables: { intensity: 0.5 } });
await act(async () => {});

expect(setVariables).not.toHaveBeenCalled();
});

it("syncs preset changes via setPreset without re-initializing", async () => {
const setPreset = vi.fn();
const scene = createMockScene({ setPreset });
addSceneMock.mockResolvedValue(scene);

const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: { ...defaultProps(elementRef), preset: "Light Theme" },
});

await act(async () => {});
expect(setPreset).not.toHaveBeenCalled();

rerender({ ...defaultProps(elementRef), preset: "Dark Theme" });
await act(async () => {});

expect(setPreset).toHaveBeenCalledWith("Dark Theme");
expect(addSceneMock).toHaveBeenCalledTimes(1);
});

it("subscribes to variable changes and unsubscribes on unmount", async () => {
const unsubscribe = vi.fn();
let subscribedCallback:
| ((name: string, value: unknown, values: object) => void)
| undefined;
const scene = createMockScene({
onVariableChange: vi.fn((cb) => {
subscribedCallback = cb;
return unsubscribe;
}),
});
addSceneMock.mockResolvedValueOnce(scene);

const onVariableChange = vi.fn();

const { unmount } = renderHook(() =>
useUnicornScene({ ...defaultProps(elementRef), onVariableChange }),
);

await act(async () => {});
expect(scene.onVariableChange).toHaveBeenCalledTimes(1);

subscribedCallback?.("intensity", 0.9, { intensity: 0.9 });
expect(onVariableChange).toHaveBeenCalledWith("intensity", 0.9, {
intensity: 0.9,
});

unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});

it("applies variables and preset that changed while scene creation was pending", async () => {
const setVariables = vi.fn();
const setPreset = vi.fn();
const scene = createMockScene({ setVariables, setPreset });

let resolveAddScene!: (value: UnicornStudioScene) => void;
addSceneMock.mockReturnValue(
new Promise<UnicornStudioScene>((resolve) => {
resolveAddScene = resolve;
}),
);

const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
variables: { intensity: 0.2 },
preset: "Dark Theme",
},
});

// Props change while addScene() is still in flight, so the scene config was
// already built with the previous values.
rerender({
...defaultProps(elementRef),
variables: { intensity: 0.9 },
preset: "Light Theme",
});

await act(async () => {
resolveAddScene(scene);
});

expect(setVariables).toHaveBeenCalledWith({ intensity: 0.9 });
expect(setPreset).toHaveBeenCalledWith("Light Theme");
});

it("does not replay variables or preset that were unchanged during creation", async () => {
const setVariables = vi.fn();
const setPreset = vi.fn();
const scene = createMockScene({ setVariables, setPreset });
addSceneMock.mockResolvedValue(scene);

renderHook(() =>
useUnicornScene({
...defaultProps(elementRef),
variables: { intensity: 0.2 },
preset: "Dark Theme",
}),
);

await act(async () => {});

// Delivered via initialVariables/initialPreset in the scene config.
expect(setVariables).not.toHaveBeenCalled();
expect(setPreset).not.toHaveBeenCalled();
});

it("handles scenes without the variables API gracefully", async () => {
const scene = createMockScene();
addSceneMock.mockResolvedValue(scene);

const { rerender } = renderHook((props) => useUnicornScene(props), {
initialProps: {
...defaultProps(elementRef),
variables: { intensity: 0.2 },
preset: "Dark Theme",
onVariableChange: vi.fn(),
},
});

await act(async () => {});

expect(() => {
rerender({
...defaultProps(elementRef),
variables: { intensity: 0.9 },
preset: "Light Theme",
onVariableChange: vi.fn(),
});
}).not.toThrow();

// The graceful path must not recreate the scene either.
expect(addSceneMock).toHaveBeenCalledTimes(1);
});

// -----------------------------------------------------------------------
// Validation errors
// -----------------------------------------------------------------------
Expand All @@ -370,7 +576,7 @@
// Error sanitization
// -----------------------------------------------------------------------

it("sanitizes 404 errors", async () => {

Check warning on line 579 in src/__tests__/useUnicornScene.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace these 3 tests with a single Parameterized one.

See more on https://sonarcloud.io/project/issues?id=diegopeixoto_unicornstudio-react&issues=AZ9HwWPKNOHYdCymSvS2&open=AZ9HwWPKNOHYdCymSvS2&pullRequest=49
addSceneMock.mockRejectedValueOnce(new Error("404 not found"));

const { result } = renderHook(() =>
Expand Down
13 changes: 12 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export { default, UnicornScene } from "./react";
export type { UnicornSceneProps } from "./shared/types";
export type {
UnicornSceneProps,
UnicornStudioScene,
UnicornVariables,
UnicornVariableValue,
UnicornVariableChangeCallback,
UnicornVariableDefinition,
UnicornVariableManifestEntry,
UnicornPreset,
UnicornVec2,
UnicornVec3,
} from "./shared/types";
Loading
Loading