diff --git a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
index 6dc94e09d7ab..b10837d2fe34 100644
--- a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
+++ b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
@@ -12,6 +12,7 @@ import { JsonControl } from "./json";
import { TextContent } from "./text-content";
import { ResourceControl } from "./resource-control";
import { TagControl } from "./tag-control";
+import { InvokerControl } from "./invoker";
export const renderControl = ({
meta,
@@ -106,6 +107,10 @@ export const renderControl = ({
return ;
}
+ if (meta.control === "invoker") {
+ return ;
+ }
+
// Type in meta can be changed at some point without updating props in DB that are still using the old type
// In this case meta and prop will mismatch, but we try to guess a matching control based just on the prop type
if (prop) {
@@ -232,6 +237,22 @@ export const renderControl = ({
);
}
+ if (prop.type === "invoker") {
+ return (
+
+ );
+ }
+
prop satisfies never;
}
diff --git a/apps/builder/app/builder/features/settings-panel/controls/invoker.tsx b/apps/builder/app/builder/features/settings-panel/controls/invoker.tsx
new file mode 100644
index 000000000000..76bd107daecb
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/controls/invoker.tsx
@@ -0,0 +1,145 @@
+import { useMemo } from "react";
+import { useStore } from "@nanostores/react";
+import {
+ Box,
+ Grid,
+ InputField,
+ Select,
+ Text,
+ theme,
+} from "@webstudio-is/design-system";
+import { type Invoker, isCompleteInvoker } from "@webstudio-is/sdk";
+import { $instances } from "~/shared/nano-states";
+import { type ControlProps, ResponsiveLayout } from "../shared";
+import { FieldLabel, PropertyLabel } from "../property-label";
+
+/**
+ * Hook to find all AnimateChildren instances in the project
+ */
+const useAnimationGroups = () => {
+ const instances = useStore($instances);
+ return useMemo(() => {
+ const groups: Array<{ id: string; label: string }> = [];
+ for (const [id, instance] of instances) {
+ if (
+ instance.component ===
+ "@webstudio-is/sdk-components-animation:AnimateChildren"
+ ) {
+ groups.push({ id, label: `Animation Group` });
+ }
+ }
+ return groups;
+ }, [instances]);
+};
+
+const defaultValue: Invoker = {
+ targetInstanceId: "",
+ command: "--",
+};
+
+/**
+ * Invoker Control - enables HTML Invoker Commands for triggering animations
+ *
+ * This creates the connection between a button and an Animation Group.
+ * When clicked, the button will dispatch a command event to the target.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ */
+export const InvokerControl = ({
+ prop,
+ propName,
+ onChange,
+}: ControlProps<"invoker">) => {
+ const animationGroups = useAnimationGroups();
+
+ const value: Invoker = prop?.type === "invoker" ? prop.value : defaultValue;
+
+ const handleChange = (updates: Partial) => {
+ const newValue = { ...value, ...updates };
+ // Ensure command always starts with --
+ if (updates.command !== undefined && !updates.command.startsWith("--")) {
+ newValue.command = `--${updates.command.replace(/^-+/, "")}`;
+ }
+ onChange({
+ type: "invoker",
+ value: newValue,
+ });
+ };
+
+ // Show message if no Animation Groups exist
+ if (animationGroups.length === 0) {
+ return (
+ }>
+
+ Add an Animation Group to use Invoker
+
+
+ );
+ }
+
+ const targetOptions = animationGroups.map((g) => g.id);
+
+ // Get display value without -- prefix for cleaner input
+ const commandDisplayValue = value.command.startsWith("--")
+ ? value.command.slice(2)
+ : value.command;
+
+ const isValid = isCompleteInvoker(value);
+
+ return (
+
+ {/* Main Property Label with Delete functionality */}
+ }>
+
+ {isValid ? `command="${value.command}"` : "Configure below"}
+
+
+
+ {/* Target Animation Group */}
+
+ Target
+
+ }
+ >
+
+
+
+
+ {/* Command Name */}
+
+ Command
+
+ }
+ >
+ --}
+ onChange={(event) => {
+ const sanitized = event.target.value
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "");
+ handleChange({ command: `--${sanitized}` });
+ }}
+ />
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx
index 5b66afd4e424..6b2c4dea5f72 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx
@@ -4,7 +4,7 @@ import { theme } from "@webstudio-is/design-system";
import { useState } from "react";
import type { ScrollAnimation, ViewAnimation } from "@webstudio-is/sdk";
-const meta = {
+const meta: Meta = {
title: "Builder/Settings Panel/Animation Panel Content",
component: AnimationPanelContent,
parameters: {
@@ -17,34 +17,66 @@ const meta = {
),
],
-} satisfies Meta;
+};
export default meta;
type Story = StoryObj;
-const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
- const [value, setValue] = useState(initialValue);
+const ScrollAnimationTemplate = () => {
+ const [value, setValue] = useState({
+ name: "scroll-animation",
+ timing: {
+ rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
+ rangeEnd: ["end", { type: "unit", value: 100, unit: "%" }],
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ opacity: { type: "unit", value: 0, unit: "number" },
+ },
+ },
+ ],
+ });
return (
{
- setValue(newValue as ScrollAnimation);
+ if (newValue !== undefined) {
+ setValue(newValue);
+ }
}}
/>
);
};
-const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
- const [value, setValue] = useState(initialValue);
+const ViewAnimationTemplate = () => {
+ const [value, setValue] = useState({
+ name: "view-animation",
+ timing: {
+ rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
+ rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }],
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ opacity: { type: "unit", value: 0, unit: "number" },
+ },
+ },
+ ],
+ });
return (
{
- setValue(newValue as ViewAnimation);
+ if (newValue !== undefined) {
+ setValue(newValue);
+ }
}}
/>
);
@@ -52,48 +84,8 @@ const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
export const ScrollAnimationStory: Story = {
render: ScrollAnimationTemplate,
- args: {
- type: "scroll",
- value: {
- name: "scroll-animation",
- timing: {
- rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
- rangeEnd: ["end", { type: "unit", value: 100, unit: "%" }],
- },
- keyframes: [
- {
- offset: 0,
- styles: {
- opacity: { type: "unit", value: 0, unit: "%" },
- color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 },
- },
- },
- ],
- },
- onChange: () => {},
- },
};
export const ViewAnimationStory: Story = {
render: ViewAnimationTemplate,
- args: {
- type: "view",
- value: {
- name: "view-animation",
- timing: {
- rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
- rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }],
- },
- keyframes: [
- {
- offset: 0,
- styles: {
- opacity: { type: "unit", value: 0, unit: "%" },
- color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 },
- },
- },
- ],
- },
- onChange: () => {},
- },
};
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx
index bb14e767ff8b..218edf98a27b 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx
@@ -5,6 +5,7 @@ import {
InputField,
ScrollArea,
Select,
+ Text,
theme,
toast,
ToggleGroup,
@@ -18,6 +19,7 @@ import type {
RangeUnitValue,
ScrollAnimation,
ViewAnimation,
+ EventAnimation,
} from "@webstudio-is/sdk";
import {
durationUnitValueSchema,
@@ -25,6 +27,7 @@ import {
rangeUnitValueSchema,
scrollAnimationSchema,
viewAnimationSchema,
+ eventAnimationSchema,
} from "@webstudio-is/sdk";
import {
CssValueInput,
@@ -291,16 +294,25 @@ const DurationInput = ({
);
};
-type AnimationPanelContentProps = {
- type: "scroll" | "view";
- value: ScrollAnimation | ViewAnimation;
-
- onChange: ((
- value: ScrollAnimation | ViewAnimation,
- isEphemeral: boolean
- ) => void) &
- ((value: undefined, isEphemeral: true) => void);
-};
+type AnimationPanelContentProps =
+ | {
+ type: "scroll";
+ value: ScrollAnimation;
+ onChange: ((value: ScrollAnimation, isEphemeral: boolean) => void) &
+ ((value: undefined, isEphemeral: true) => void);
+ }
+ | {
+ type: "view";
+ value: ViewAnimation;
+ onChange: ((value: ViewAnimation, isEphemeral: boolean) => void) &
+ ((value: undefined, isEphemeral: true) => void);
+ }
+ | {
+ type: "event";
+ value: EventAnimation;
+ onChange: ((value: EventAnimation, isEphemeral: boolean) => void) &
+ ((value: undefined, isEphemeral: true) => void);
+ };
const defaultRangeStart = {
type: "unit",
@@ -324,6 +336,294 @@ const PanelContainer = ({ children }: { children: ReactNode }) => {
);
};
+const animationDirections = [
+ "normal",
+ "reverse",
+ "alternate",
+ "alternate-reverse",
+] as const;
+
+type EventAnimationPanelProps = {
+ value: EventAnimation;
+ onChange: ((value: EventAnimation, isEphemeral: boolean) => void) &
+ ((value: undefined, isEphemeral: true) => void);
+};
+
+const EventAnimationPanel = ({ value, onChange }: EventAnimationPanelProps) => {
+ const handleChange = (rawValue: unknown, isEphemeral: boolean) => {
+ if (rawValue === undefined) {
+ onChange(undefined, true);
+ return;
+ }
+
+ const parsedValue = eventAnimationSchema.safeParse(rawValue);
+
+ if (parsedValue.success) {
+ onChange(parsedValue.data, isEphemeral);
+ return;
+ }
+
+ console.error(parsedValue.error.format());
+ toast.error("Animation schema is incompatible, try fix");
+ };
+
+ const iterationsValue =
+ value.timing.iterations === "infinite"
+ ? "infinite"
+ : (value.timing.iterations?.toString() ?? "");
+
+ const playbackRateValue = value.timing.playbackRate?.toString() ?? "";
+
+ return (
+
+
+ {/* Animation Identity */}
+
+
+ Name
+
+ {
+ const name = event.currentTarget.value;
+
+ const newValue = {
+ ...value,
+ name,
+ };
+
+ handleChange(newValue, false);
+ }}
+ />
+
+
+ {/* Timing Configuration */}
+
+ Timing Configuration
+
+
+
+
+ Fill Mode
+
+
+
+
+
+ Easing
+
+ {
+ if (easing === undefined && isEphemeral) {
+ handleChange(undefined, true);
+ return;
+ }
+
+ handleChange(
+ {
+ ...value,
+ timing: {
+ ...value.timing,
+ easing,
+ },
+ },
+ isEphemeral
+ );
+ }}
+ />
+
+
+
+
+ Direction
+
+
+
+
+
+ Iterations
+
+ {
+ const raw = event.currentTarget.value.trim();
+ const iterations =
+ raw === ""
+ ? undefined
+ : raw === "infinite"
+ ? "infinite"
+ : Number(raw);
+
+ handleChange(
+ {
+ ...value,
+ timing: {
+ ...value.timing,
+ iterations:
+ iterations === undefined || Number.isNaN(iterations)
+ ? undefined
+ : iterations,
+ },
+ },
+ false
+ );
+ }}
+ />
+
+
+
+
+ Playback Rate
+
+ {
+ const raw = event.currentTarget.value.trim();
+ const playbackRate =
+ raw === "" ? undefined : Number.parseFloat(raw);
+
+ handleChange(
+ {
+ ...value,
+ timing: {
+ ...value.timing,
+ playbackRate: Number.isNaN(playbackRate)
+ ? undefined
+ : playbackRate,
+ },
+ },
+ false
+ );
+ }}
+ />
+
+
+
+
+ Duration
+
+ {
+ if (duration === undefined && isEphemeral) {
+ handleChange(undefined, true);
+ return;
+ }
+
+ handleChange(
+ {
+ ...value,
+ timing: {
+ ...value.timing,
+ duration,
+ },
+ },
+ isEphemeral
+ );
+ }}
+ />
+
+
+
+ {
+ if (keyframes === undefined && isEphemeral) {
+ handleChange(undefined, true);
+ return;
+ }
+
+ handleChange({ ...value, keyframes }, isEphemeral);
+ }}
+ />
+
+ );
+};
+
const simplifiedRanges = [
[
"cover 0%",
@@ -404,28 +704,53 @@ export const AnimationPanelContent = ({
value,
type,
}: AnimationPanelContentProps) => {
- const startRangeIndex = simplifiedStartRanges.findIndex(([, , range]) =>
- isRangeEqual(range, value.timing.rangeStart)
+ // Compute range values for scroll/view animations (safe to call even for event type)
+ const scrollViewValue = value as ScrollAnimation | ViewAnimation;
+
+ const [startRangeValue] =
+ type !== "event"
+ ? (simplifiedStartRanges.find(([, , range]) =>
+ isRangeEqual(range, scrollViewValue.timing.rangeStart)
+ ) ?? [undefined, undefined, undefined])
+ : [undefined, undefined, undefined];
+
+ const [endRangeValue] =
+ type !== "event"
+ ? (simplifiedEndRanges.find(([, , range]) =>
+ isRangeEqual(range, scrollViewValue.timing.rangeEnd)
+ ) ?? [undefined, undefined, undefined])
+ : [undefined, undefined, undefined];
+
+ // Hooks must be called unconditionally before any early returns
+ const [isAdvancedRangeStart, setIsAdvancedRangeStart] = useState(
+ () => startRangeValue === undefined
);
- const [startRangeValue] = simplifiedStartRanges.find(([, , range]) =>
- isRangeEqual(range, value.timing.rangeStart)
- ) ?? [undefined, undefined, undefined];
-
- const endRangeIndex = simplifiedEndRanges.findIndex(([, , range]) =>
- isRangeEqual(range, value.timing.rangeEnd)
+ const [isAdvancedRangeEnd, setIsAdvancedRangeEnd] = useState(
+ () => endRangeValue === undefined
);
- const [endRangeValue] = simplifiedEndRanges.find(([, , range]) =>
- isRangeEqual(range, value.timing.rangeEnd)
- ) ?? [undefined, undefined, undefined];
+ // Early return for event animations after hooks
+ if (type === "event") {
+ return (
+ void
+ }
+ />
+ );
+ }
- const [isAdvancedRangeStart, setIsAdvancedRangeStart] = useState(
- () => startRangeValue === undefined
+ const startRangeIndex = simplifiedStartRanges.findIndex(([, , range]) =>
+ isRangeEqual(range, value.timing.rangeStart)
);
- const [isAdvancedRangeEnd, setIsAdvancedRangeEnd] = useState(
- () => endRangeValue === undefined
+ const endRangeIndex = simplifiedEndRanges.findIndex(([, , range]) =>
+ isRangeEqual(range, value.timing.rangeEnd)
);
const isScrollAnimation = type === "scroll";
@@ -451,7 +776,14 @@ export const AnimationPanelContent = ({
const parsedValue = animationSchema.safeParse(rawValue);
if (parsedValue.success) {
- onChange(parsedValue.data, isEphemeral);
+ // Type assertion needed because onChange is a discriminated union
+ // but parsedValue.data is typed as ScrollAnimation | ViewAnimation
+ (
+ onChange as (
+ value: ScrollAnimation | ViewAnimation,
+ isEphemeral: boolean
+ ) => void
+ )(parsedValue.data, isEphemeral);
return;
}
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx
index 4eaba6a2db5d..538da52e526e 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx
@@ -14,10 +14,13 @@ import {
Switch,
FloatingPanel,
IconButton,
+ InputField,
+ Flex,
} from "@webstudio-is/design-system";
import type {
AnimationAction,
AnimationActionScroll,
+ AnimationActionEvent,
InsetUnitValue,
} from "@webstudio-is/sdk";
import {
@@ -29,6 +32,8 @@ import {
ArrowDownIcon,
ArrowRightIcon,
EllipsesIcon,
+ PlusIcon,
+ MinusIcon,
} from "@webstudio-is/icons";
import { toValue, type StyleValue } from "@webstudio-is/css-engine";
import {
@@ -40,11 +45,14 @@ import { FieldLabel } from "../../property-label";
import type { PropAndMeta } from "../use-props-logic";
import { AnimationsSelect } from "./animations-select";
import { SubjectSelect } from "./subject-select";
+import { TargetSelect } from "./target-select";
const animationTypeDescription: Record = {
scroll:
"Scroll-based animations are triggered and controlled by the user’s scroll position.",
view: "View-based animations occur when an element enters or exits the viewport. They rely on the element’s visibility rather than the scroll position.",
+ event:
+ "Command-driven animations start on user interaction (click, focus, keyboard) rather than scroll or view progress.",
};
const insetDescription =
@@ -59,8 +67,38 @@ const defaultActionValue: AnimationAction = {
animations: [],
};
+const defaultEventAction: AnimationAction = {
+ type: "event",
+ target: "self",
+ triggers: [{ kind: "click" }],
+ command: "play",
+ animations: [],
+ respectReducedMotion: true,
+};
+
+const eventCommands = [
+ "play",
+ "pause",
+ "toggle",
+ "restart",
+ "reverse",
+ "seek",
+] as const;
+
+const eventTriggerKinds = [
+ "click",
+ "dblclick",
+ "pointerenter",
+ "pointerleave",
+ "focus",
+ "blur",
+ "keydown",
+ "keyup",
+ "command",
+] as const;
+
const animationAxisDescription: Record<
- Exclude, "block" | "inline">,
+ Exclude, "block" | "inline">,
{ icon: React.ReactNode; label: string; description: React.ReactNode }
> = {
/*
@@ -95,7 +133,7 @@ const animationAxisDescription: Record<
/**
* Support for block and inline axis is removed, as it is not widely used.
*/
-const convertAxisToXY = (axis: NonNullable) => {
+const convertAxisToXY = (axis: NonNullable) => {
switch (axis) {
case "block":
return "y";
@@ -211,6 +249,349 @@ const AnimationConfig = ({
onChange: ((value: AnimationAction, isEphemeral: boolean) => void) &
((value: undefined, isEphemeral: true) => void);
}) => {
+ if (value.type === "event") {
+ const triggers =
+ value.triggers?.length === 0 || value.triggers === undefined
+ ? defaultEventAction.triggers
+ : value.triggers;
+
+ const addTrigger = () => {
+ onChange({ ...value, triggers: [...triggers, { kind: "click" }] }, false);
+ };
+
+ const updateTrigger = (
+ index: number,
+ trigger: AnimationActionEvent["triggers"][number]
+ ) => {
+ const newTriggers = [...triggers] as AnimationActionEvent["triggers"];
+ newTriggers[index] = trigger;
+ onChange({ ...value, triggers: newTriggers }, false);
+ };
+
+ const removeTrigger = (index: number) => {
+ if (triggers.length <= 1) {
+ return;
+ }
+ const newTriggers = [...triggers];
+ newTriggers.splice(index, 1);
+ onChange(
+ { ...value, triggers: newTriggers as AnimationActionEvent["triggers"] },
+ false
+ );
+ };
+
+ const targetMode =
+ value.target === undefined || value.target === "self" ? "self" : "custom";
+ const targetValue =
+ targetMode === "self" ? "self" : (value.target as string);
+
+ return (
+
+ {/* Type selector for event animations */}
+
+
+ Type
+
+
+
+
+
+ {/* Target Configuration Section */}
+
+ Target Configuration
+
+
+ Element
+
+
+ {
+ if (mode === "self") {
+ onChange({ ...value, target: "self" }, false);
+ return;
+ }
+ onChange(
+ {
+ ...value,
+ target: targetValue === "self" ? "" : targetValue,
+ },
+ false
+ );
+ }}
+ >
+
+ Self
+
+
+ Custom
+
+
+ {targetMode === "custom" && (
+ {
+ onChange({ ...value, target }, false);
+ }}
+ />
+ )}
+
+
+
+
+
+
+ {/* Command Configuration Section */}
+
+ Command Configuration
+
+
+
+ Action
+
+
+ {
+ onChange(
+ {
+ ...value,
+ command,
+ seekTo:
+ command === "seek" ? (value.seekTo ?? 0.5) : undefined,
+ },
+ false
+ );
+ }}
+ >
+
+ Play
+
+
+ Pause
+
+
+ Toggle
+
+
+ Restart
+
+
+ Reverse
+
+
+ Seek
+
+
+
+ {value.command === "seek" && (
+
+
+ Position
+
+ {((value.seekTo ?? 0.5) * 100).toFixed(0)}%
+
+
+ {
+ const seekTo = Number.parseFloat(
+ event.currentTarget.value
+ );
+ onChange(
+ {
+ ...value,
+ seekTo: Number.isNaN(seekTo)
+ ? undefined
+ : Math.min(1, Math.max(0, seekTo)),
+ },
+ false
+ );
+ }}
+ />
+
+ )}
+
+
+
+
+
+ Respect Reduced Motion
+
+
+ {
+ onChange({ ...value, respectReducedMotion }, false);
+ }}
+ />
+
+
+
+
+
+
+ {/* Trigger Configuration Section */}
+
+
+ Trigger Configuration
+
+
+
+
+
+
+
+
+ {triggers.map((trigger, index) => (
+
+
+
+
+
+ {(trigger.kind === "keydown" || trigger.kind === "keyup") && (
+ {
+ let key = event.currentTarget.value;
+ // Convert friendly names to actual KeyboardEvent.key values
+ if (key.toLowerCase() === "space") {
+ key = " ";
+ }
+ updateTrigger(index, {
+ ...trigger,
+ key,
+ });
+ }}
+ />
+ )}
+
+ {trigger.kind === "command" && (
+ {
+ let command = event.currentTarget.value;
+ // Ensure command starts with --
+ if (!command.startsWith("--")) {
+ command = `--${command.replace(/^-+/, "")}`;
+ }
+ updateTrigger(index, {
+ kind: "command",
+ command,
+ });
+ }}
+ />
+ )}
+
+
+ ))}
+
+
+
+ );
+ }
return (
@@ -226,14 +607,24 @@ const AnimationConfig = ({
{animationTypeDescription[animationType]}
)}
- onChange={(typeValue) =>
- onChange({ ...value, type: typeValue, animations: [] }, false)
- }
+ onChange={(typeValue) => {
+ if (typeValue === "event") {
+ onChange(
+ {
+ ...defaultEventAction,
+ animations: [],
+ },
+ false
+ );
+ return;
+ }
+ onChange({ ...value, type: typeValue, animations: [] }, false);
+ }}
/>
-
+
Axis
void);
}
>(({ value, onChange, ...props }, ref) => {
- const { animations: defaultAnimations, ...defaultValue } = defaultActionValue;
+ const baseDefault =
+ value.type === "event" ? defaultEventAction : defaultActionValue;
+ const { animations: defaultAnimations, ...defaultValue } = baseDefault;
const { animations, ...newValue } = value;
return (
@@ -378,6 +771,18 @@ export const AnimationSection = ({
const value: AnimationAction =
prop?.type === "animationAction" ? prop.value : defaultActionValue;
+ const currentValue: AnimationAction =
+ value.type === "event"
+ ? {
+ ...defaultEventAction,
+ ...value,
+ triggers:
+ value.triggers?.length && value.triggers.length > 0
+ ? value.triggers
+ : defaultEventAction.triggers,
+ }
+ : value;
+
const handleChange = (value: unknown, isEphemeral: boolean) => {
if (value === undefined && isEphemeral) {
onChange(undefined, isEphemeral);
@@ -400,11 +805,11 @@ export const AnimationSection = ({
Run on canvas
-
+
{
- handleChange({ ...value, isPinned }, false);
+ handleChange({ ...currentValue, isPinned }, false);
}}
/>
@@ -416,9 +821,9 @@ export const AnimationSection = ({
{
- handleChange({ ...value, debug }, false);
+ handleChange({ ...currentValue, debug }, false);
}}
/>
@@ -433,13 +838,16 @@ export const AnimationSection = ({
title="Advanced Animation"
placement="bottom"
content={
-
+
}
>
-
+
}
- value={value}
+ value={currentValue}
onChange={handleChange}
isAnimationEnabled={isAnimationEnabled}
selectedBreakpointId={selectedBreakpointId}
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
index b57b7ab0ddeb..bba788cf75b5 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx
@@ -1,4 +1,10 @@
-import { useState, useMemo, type ReactNode, useRef } from "react";
+import {
+ useState,
+ useMemo,
+ type ReactNode,
+ useRef,
+ type ComponentProps,
+} from "react";
import {
theme,
DropdownMenu,
@@ -37,12 +43,15 @@ import {
import {
scrollAnimationSchema,
viewAnimationSchema,
+ eventAnimationSchema,
type AnimationAction,
type ScrollAnimation,
type ViewAnimation,
+ type EventAnimation,
} from "@webstudio-is/sdk";
import { newScrollAnimations } from "./new-scroll-animations";
import { newViewAnimations } from "./new-view-animations";
+import { newEventAnimations } from "./new-event-animations";
import { AnimationPanelContent } from "./animation-panel-content";
import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
import { z } from "zod";
@@ -50,9 +59,11 @@ import { z } from "zod";
const newAnimationsPerType: {
scroll: ScrollAnimation[];
view: ViewAnimation[];
+ event: EventAnimation[];
} = {
scroll: newScrollAnimations,
view: newViewAnimations,
+ event: newEventAnimations,
};
type AnimationsSelectProps = {
@@ -72,7 +83,9 @@ const copyAttribute = "data-animation-index";
const clipboardNamespace = "@webstudio/animation/v0.1";
-const serialize = (animations: (ScrollAnimation | ViewAnimation)[]) => {
+const serialize = (
+ animations: (ScrollAnimation | ViewAnimation | EventAnimation)[]
+) => {
return JSON.stringify({ [clipboardNamespace]: animations });
};
@@ -92,6 +105,14 @@ const parseScrollAnimations = (text: string): ScrollAnimation[] => {
return parsed[clipboardNamespace];
};
+const parseEventAnimations = (text: string): EventAnimation[] => {
+ const data = JSON.parse(text);
+ const parsed = z
+ .object({ [clipboardNamespace]: z.array(eventAnimationSchema) })
+ .parse(data);
+ return parsed[clipboardNamespace];
+};
+
const AnimationContextMenu = ({
action,
onChange,
@@ -131,6 +152,12 @@ const AnimationContextMenu = ({
newAction.animations.splice(index + 1, 0, ...animations);
onChange(newAction);
}
+ if (action.type === "event") {
+ const animations = parseEventAnimations(text);
+ const newAction = structuredClone(action);
+ newAction.animations.splice(index + 1, 0, ...animations);
+ onChange(newAction);
+ }
})
.catch((error) => {
toast.error("Pasted data is not valid animation");
@@ -239,10 +266,15 @@ export const AnimationsSelect = ({
{
+ // For event animations, replace instead of concat (only 1 animation allowed)
+ const newAnimations =
+ value.type === "event"
+ ? [animation]
+ : value.animations.concat(animation);
handleChange(
{
...value,
- animations: value.animations.concat(animation),
+ animations: newAnimations,
},
false
);
@@ -278,7 +310,9 @@ export const AnimationsSelect = ({
}}
>
{newAnimationHint ??
- "Add new or select existing animation"}
+ (value.type === "event"
+ ? "Select animation (replaces current)"
+ : "Add new or select existing animation")}
@@ -305,23 +339,33 @@ export const AnimationsSelect = ({
}
content={
{
- if (animation === undefined) {
- // Reset ephemeral state
- handleChange(undefined, true);
- return;
- }
+ {...({
+ type: value.type,
+ value: animation,
+ onChange: (
+ animation:
+ | ScrollAnimation
+ | ViewAnimation
+ | EventAnimation
+ | undefined,
+ isEphemeral: boolean
+ ) => {
+ if (animation === undefined) {
+ // Reset ephemeral state
+ handleChange(undefined, true);
+ return;
+ }
- const newAnimations = [...value.animations];
- newAnimations[index] = animation;
- const newValue = {
- ...value,
- animations: newAnimations,
- };
- handleChange(newValue, isEphemeral);
- }}
+ const newAnimations = [...value.animations];
+ newAnimations[index] =
+ animation as (typeof newAnimations)[number];
+ const newValue = {
+ ...value,
+ animations: newAnimations,
+ };
+ handleChange(newValue, isEphemeral);
+ },
+ } as ComponentProps)}
/>
}
offset={floatingPanelOffset}
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/new-event-animations.ts b/apps/builder/app/builder/features/settings-panel/props-section/animation/new-event-animations.ts
new file mode 100644
index 000000000000..3e4460ac19fd
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/new-event-animations.ts
@@ -0,0 +1,230 @@
+import { parseCssValue } from "@webstudio-is/css-data";
+import type { EventAnimation } from "@webstudio-is/sdk";
+
+export const newEventAnimations: EventAnimation[] = [
+ {
+ name: "Fade In",
+ description: "Smoothly fade in an element when triggered",
+ timing: {
+ duration: { type: "unit", value: 300, unit: "ms" },
+ fill: "both",
+ easing: "ease-in-out",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ opacity: parseCssValue("opacity", "0"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ opacity: parseCssValue("opacity", "1"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Scale Up",
+ description: "Scale up element from small to normal size",
+ timing: {
+ duration: { type: "unit", value: 250, unit: "ms" },
+ fill: "both",
+ easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ transform: parseCssValue("transform", "scale(0.8)"),
+ opacity: parseCssValue("opacity", "0"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ transform: parseCssValue("transform", "scale(1)"),
+ opacity: parseCssValue("opacity", "1"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Slide In From Left",
+ description: "Slide element in from the left side",
+ timing: {
+ duration: { type: "unit", value: 400, unit: "ms" },
+ fill: "both",
+ easing: "ease-out",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ transform: parseCssValue("transform", "translateX(-100%)"),
+ opacity: parseCssValue("opacity", "0"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ transform: parseCssValue("transform", "translateX(0)"),
+ opacity: parseCssValue("opacity", "1"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Rotate In",
+ description: "Rotate element into view with a spin effect",
+ timing: {
+ duration: { type: "unit", value: 500, unit: "ms" },
+ fill: "both",
+ easing: "ease-in-out",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ transform: parseCssValue("transform", "rotate(-180deg) scale(0)"),
+ opacity: parseCssValue("opacity", "0"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ transform: parseCssValue("transform", "rotate(0deg) scale(1)"),
+ opacity: parseCssValue("opacity", "1"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Bounce",
+ description: "Playful bounce animation",
+ timing: {
+ duration: { type: "unit", value: 600, unit: "ms" },
+ fill: "both",
+ easing: "linear",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ transform: parseCssValue("transform", "translateY(0)"),
+ },
+ },
+ {
+ offset: 0.2,
+ styles: {
+ transform: parseCssValue("transform", "translateY(-30px)"),
+ },
+ },
+ {
+ offset: 0.5,
+ styles: {
+ transform: parseCssValue("transform", "translateY(0)"),
+ },
+ },
+ {
+ offset: 0.7,
+ styles: {
+ transform: parseCssValue("transform", "translateY(-15px)"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ transform: parseCssValue("transform", "translateY(0)"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Shake",
+ description: "Attention-grabbing shake effect",
+ timing: {
+ duration: { type: "unit", value: 400, unit: "ms" },
+ fill: "both",
+ easing: "linear",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {
+ transform: parseCssValue("transform", "translateX(0)"),
+ },
+ },
+ {
+ offset: 0.1,
+ styles: {
+ transform: parseCssValue("transform", "translateX(-10px)"),
+ },
+ },
+ {
+ offset: 0.3,
+ styles: {
+ transform: parseCssValue("transform", "translateX(10px)"),
+ },
+ },
+ {
+ offset: 0.5,
+ styles: {
+ transform: parseCssValue("transform", "translateX(-10px)"),
+ },
+ },
+ {
+ offset: 0.7,
+ styles: {
+ transform: parseCssValue("transform", "translateX(10px)"),
+ },
+ },
+ {
+ offset: 0.9,
+ styles: {
+ transform: parseCssValue("transform", "translateX(-5px)"),
+ },
+ },
+ {
+ offset: 1,
+ styles: {
+ transform: parseCssValue("transform", "translateX(0)"),
+ },
+ },
+ ],
+ },
+ {
+ name: "Custom Animation",
+ description: "Start with a blank animation to create your own",
+ timing: {
+ duration: { type: "unit", value: 300, unit: "ms" },
+ fill: "both",
+ easing: "ease",
+ direction: "normal",
+ iterations: 1,
+ },
+ keyframes: [
+ {
+ offset: 0,
+ styles: {},
+ },
+ {
+ offset: 1,
+ styles: {},
+ },
+ ],
+ },
+];
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/target-select.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/target-select.tsx
new file mode 100644
index 000000000000..9da858fc6d3d
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/target-select.tsx
@@ -0,0 +1,130 @@
+import { useMemo } from "react";
+import { useStore } from "@nanostores/react";
+import { Box, Select } from "@webstudio-is/design-system";
+import {
+ rootComponent,
+ descendantComponent,
+ collectionComponent,
+ blockComponent,
+ blockTemplateComponent,
+} from "@webstudio-is/sdk";
+import {
+ $hoveredInstanceSelector,
+ $instances,
+ $registeredComponentMetas,
+ $selectedInstanceSelector,
+} from "~/shared/nano-states";
+import { getInstanceLabel } from "~/builder/shared/instance-label";
+
+type TargetElement = {
+ id: string;
+ label: string;
+ selector: string[];
+};
+
+/**
+ * Components that should not be available as animation targets
+ */
+const excludedComponents = new Set([
+ // Core system components
+ rootComponent, // ws:root - HTML root
+ descendantComponent, // ws:descendant - style-only
+ collectionComponent, // ws:collection - data container
+ blockComponent, // ws:block
+ blockTemplateComponent, // ws:blockTemplate
+ // Animation components (they contain animations, not targets)
+ "@webstudio-is/sdk-components-animation:AnimateChildren",
+ // Structural components that shouldn't be animated
+ "Body",
+]);
+
+/**
+ * Hook to find all elements that can be animated (for Custom Target dropdown)
+ * Returns all instances with their selectors for hover highlighting
+ */
+const useAnimatableElements = (): TargetElement[] => {
+ const instances = useStore($instances);
+ const metas = useStore($registeredComponentMetas);
+ const selectedInstanceSelector = useStore($selectedInstanceSelector);
+
+ return useMemo(() => {
+ const elements: TargetElement[] = [];
+
+ // Build a map of parent relationships for selector construction
+ const parentMap = new Map();
+ for (const [, instance] of instances) {
+ for (const child of instance.children) {
+ if (child.type === "id") {
+ parentMap.set(child.value, instance.id);
+ }
+ }
+ }
+
+ // Build selector for an instance (array of ids from instance to root)
+ const buildSelector = (instanceId: string): string[] => {
+ const selector: string[] = [instanceId];
+ let currentId = instanceId;
+ while (parentMap.has(currentId)) {
+ currentId = parentMap.get(currentId)!;
+ selector.push(currentId);
+ }
+ return selector;
+ };
+
+ for (const [id, instance] of instances) {
+ // Skip excluded components (system, animation, structural)
+ if (excludedComponents.has(instance.component)) {
+ continue;
+ }
+
+ // Skip the currently selected instance (can't target self via Custom)
+ if (selectedInstanceSelector?.[0] === id) {
+ continue;
+ }
+
+ const meta = metas.get(instance.component);
+ const label = getInstanceLabel(instance, meta);
+
+ elements.push({
+ id,
+ label,
+ selector: buildSelector(id),
+ });
+ }
+
+ return elements;
+ }, [instances, metas, selectedInstanceSelector]);
+};
+
+type TargetSelectProps = {
+ value: string | undefined;
+ onChange: (target: string) => void;
+};
+
+/**
+ * Target Select for Custom animation target
+ * Shows all animatable elements with hover highlighting in canvas
+ */
+export const TargetSelect = ({ value, onChange }: TargetSelectProps) => {
+ const elements = useAnimatableElements();
+
+ return (
+
+
+ );
+};
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts
index 1eff9bb6dbed..4cd13839ef88 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts
+++ b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts
@@ -82,6 +82,18 @@ const getStartingValue = (
value: [],
};
}
+
+ // Invoker: provide empty initial values (schema now allows empty strings)
+ // The component generator will skip rendering if values are incomplete
+ if (meta.type === "invoker") {
+ return {
+ type: "invoker",
+ value: {
+ targetInstanceId: "",
+ command: "--",
+ },
+ };
+ }
};
const getDefaultMetaForType = (type: Prop["type"]): PropMeta => {
@@ -109,6 +121,12 @@ const getDefaultMetaForType = (type: Prop["type"]): PropMeta => {
control: "animationAction",
required: false,
};
+ case "invoker":
+ return {
+ type: "invoker",
+ control: "invoker",
+ required: false,
+ };
case "json":
throw new Error(
"A prop with type json must have a meta, we can't provide a default one because we need a list of options"
diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx
index 9e6f9b2f6c15..144049999445 100644
--- a/apps/builder/app/builder/features/settings-panel/shared.tsx
+++ b/apps/builder/app/builder/features/settings-panel/shared.tsx
@@ -78,6 +78,10 @@ export type PropValue =
| {
type: "animationAction";
value: Extract["value"];
+ }
+ | {
+ type: "invoker";
+ value: Extract["value"];
};
// Weird code is to make type distributive
@@ -458,6 +462,25 @@ const attributeToMeta = (attribute: Attribute): PropMeta => {
throw Error("impossible case");
};
+/**
+ * Invoker prop meta - enables any HTML element to trigger animations
+ * on target Animation Group elements using HTML Invoker Commands.
+ *
+ * This implements the HTML Invoker Commands API which allows declarative
+ * button-to-element command passing without JavaScript. The `invoker` prop
+ * generates `commandfor` and `command` HTML attributes.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ * @see https://open-ui.org/components/invokers.explainer/
+ */
+const invokerPropMeta: PropMeta = {
+ required: false,
+ control: "invoker",
+ type: "invoker",
+ description:
+ "Trigger animations on an Animation Group when this element is clicked.",
+};
+
export const $selectedInstancePropsMetas = computed(
[$selectedInstance, $registeredComponentMetas, $instanceTags],
(instance, metas, instanceTags): Map => {
@@ -479,6 +502,9 @@ export const $selectedInstancePropsMetas = computed(
propsMetas.set(attribute.name, attributeToMeta(attribute));
}
}
+ // Add invoker prop as a global attribute for HTML Invoker Commands API
+ // This enables any element to trigger animations via commandfor/command
+ propsMetas.set("invoker", invokerPropMeta);
}
if (attributesByTag[tag]) {
for (const attribute of [...attributesByTag[tag]].reverse()) {
diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
index a8f5ca2ac499..a85c0da567e7 100644
--- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
+++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx
@@ -17,6 +17,7 @@ import { mergeRefs } from "@react-aria/utils";
import type {
Instance,
Instances,
+ Invoker,
Prop,
WsComponentMeta,
} from "@webstudio-is/sdk";
@@ -347,6 +348,29 @@ const useCollapsedOnNewElement = (instanceId: Instance["id"]) => {
}, [instanceId]);
};
+/**
+ * HTML Invoker Commands - dispatch command events to target elements
+ * This is a polyfill for browsers that don't support native HTML Invoker Commands
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ */
+const createInvokerClickHandler = (invoker: Invoker) => {
+ return () => {
+ const target = document.querySelector(
+ `[data-ws-id="${CSS.escape(invoker.targetInstanceId)}"]`
+ );
+ if (target) {
+ // Dispatch "command" event with command name in detail
+ // Compatible with native CommandEvent when it becomes available
+ target.dispatchEvent(
+ new CustomEvent("command", {
+ detail: { command: invoker.command },
+ bubbles: false,
+ })
+ );
+ }
+ };
+};
+
/**
* We combine Radix's implicit event handlers with user-defined ones,
* such as onClick or onSubmit. For instance, a Button within
@@ -365,6 +389,27 @@ const mergeProps = (
) => {
// merge props into single object
const props = { ...restProps, ...instanceProps };
+
+ // Handle HTML Invoker Commands prop
+ const invoker = instanceProps.invoker as Invoker | undefined;
+ if (invoker && invoker.targetInstanceId && invoker.command) {
+ // Add HTML Invoker attributes for native support
+ props.commandfor = invoker.targetInstanceId;
+ props.command = invoker.command;
+ // Remove the invoker prop (not a valid HTML attribute)
+ delete props.invoker;
+
+ // Add polyfill click handler for browsers without native support
+ if (callbackStrategy === "merge") {
+ const invokerHandler = createInvokerClickHandler(invoker);
+ const existingOnClick = props.onClick;
+ props.onClick = (...args: unknown[]) => {
+ existingOnClick?.(...args);
+ invokerHandler();
+ };
+ }
+ }
+
for (const propName of Object.keys(props)) {
const restPropValue = restProps[propName];
const instancePropValue = instanceProps[propName];
diff --git a/packages/feature-flags/src/flags.ts b/packages/feature-flags/src/flags.ts
index 0a952b87c8df..a0e57b17d0ec 100644
--- a/packages/feature-flags/src/flags.ts
+++ b/packages/feature-flags/src/flags.ts
@@ -3,3 +3,4 @@ export const internalComponents = false;
export const unsupportedBrowsers = false;
export const resourceProp = false;
export const tailwind = false;
+export const commandAnimations = true;
diff --git a/packages/react-sdk/src/component-generator.ts b/packages/react-sdk/src/component-generator.ts
index aa62450d6e3a..78cff2f5a68e 100644
--- a/packages/react-sdk/src/component-generator.ts
+++ b/packages/react-sdk/src/component-generator.ts
@@ -20,6 +20,7 @@ import {
descendantComponent,
getIndexesWithinAncestors,
elementComponent,
+ isCompleteInvoker,
} from "@webstudio-is/sdk";
import { indexProperty, tagProperty } from "@webstudio-is/sdk/runtime";
import { isAttributeNameSafe, showAttribute } from "./props";
@@ -114,6 +115,13 @@ const generatePropValue = ({
) {
return JSON.stringify(prop.value);
}
+ // Only render invoker if it has valid values
+ if (prop.type === "invoker") {
+ if (isCompleteInvoker(prop.value)) {
+ return JSON.stringify(prop.value);
+ }
+ return undefined;
+ }
// generate variable name for parameter
if (prop.type === "parameter") {
const dataSource = dataSources.get(prop.value);
@@ -198,6 +206,17 @@ export const generateJsxElement = ({
continue;
}
+ // Handle invoker prop specially - render as commandfor and command attributes
+ if (prop.type === "invoker" && prop.name === "invoker") {
+ if (isCompleteInvoker(prop.value)) {
+ // commandfor references the target element's ID
+ generatedProps += `\ncommandfor={${JSON.stringify(prop.value.targetInstanceId)}}`;
+ // command is the custom command name (e.g., "--play-intro")
+ generatedProps += `\ncommand={${JSON.stringify(prop.value.command)}}`;
+ }
+ continue;
+ }
+
const propValue = generatePropValue({
scope,
prop,
diff --git a/packages/sdk-components-animation/src/animate-children.tsx b/packages/sdk-components-animation/src/animate-children.tsx
index d4513ac395ef..f7a35e6438a1 100644
--- a/packages/sdk-components-animation/src/animate-children.tsx
+++ b/packages/sdk-components-animation/src/animate-children.tsx
@@ -1,17 +1,512 @@
-import { forwardRef, type ElementRef } from "react";
+import { forwardRef, useEffect, useMemo, useRef, type ElementRef } from "react";
import type { Hook } from "@webstudio-is/react-sdk";
-import type { AnimationAction } from "@webstudio-is/sdk";
+import type {
+ AnimationAction,
+ AnimationActionEvent,
+ EventAnimation,
+} from "@webstudio-is/sdk";
import { animationCanPlayOnCanvasProperty } from "@webstudio-is/sdk/runtime";
+import { isFeatureEnabled } from "@webstudio-is/feature-flags";
+import { toValue } from "@webstudio-is/css-engine";
+
+/**
+ * CommandEvent interface - native HTML Invoker Commands API
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ */
+interface CommandEvent extends Event {
+ command: string;
+ source: Element | null;
+}
+
+/**
+ * Extract command from event - supports both native CommandEvent and polyfill CustomEvent
+ */
+const getCommandFromEvent = (event: Event): string | undefined => {
+ // Native CommandEvent
+ if (
+ "command" in event &&
+ typeof (event as CommandEvent).command === "string"
+ ) {
+ return (event as CommandEvent).command;
+ }
+ // Polyfill CustomEvent with command in detail
+ if (event instanceof CustomEvent && event.detail?.command) {
+ return event.detail.command as string;
+ }
+ return undefined;
+};
+
+/**
+ * Polyfill for HTML Invoker Commands API
+ * Handles click events on elements with commandfor/command attributes
+ * and dispatches the command event to the target element
+ */
+let polyfillInstalled = false;
+const installInvokerPolyfill = () => {
+ if (polyfillInstalled) {
+ return;
+ }
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ polyfillInstalled = true;
+
+ document.addEventListener("click", (event) => {
+ const target = event.target as Element;
+ if (!target || !("closest" in target)) {
+ return;
+ }
+
+ // Find the closest element with commandfor attribute (could be the target or an ancestor)
+ const invoker = target.closest("[commandfor]");
+ if (!invoker) {
+ return;
+ }
+
+ const commandforId = invoker.getAttribute("commandfor");
+ const command = invoker.getAttribute("command");
+
+ if (!commandforId || !command) {
+ return;
+ }
+
+ // Find the target element by ID
+ const commandTarget = document.getElementById(commandforId);
+ if (!commandTarget) {
+ return;
+ }
+
+ // Dispatch the command event to the target
+ const commandEvent = new CustomEvent("command", {
+ bubbles: true,
+ cancelable: true,
+ detail: {
+ command,
+ source: invoker,
+ },
+ });
+
+ commandTarget.dispatchEvent(commandEvent);
+ });
+};
+
+const isEventAction = (
+ action: AnimationAction | undefined
+): action is AnimationActionEvent => {
+ return action !== undefined && action.type === "event";
+};
+
+type KeyframesAndTiming = {
+ keyframes: Keyframe[];
+ timing: KeyframeAnimationOptions;
+};
+
+const convertDuration = (value: EventAnimation["timing"]["duration"]) => {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (value.type === "unit") {
+ return value.unit === "s" ? value.value * 1000 : value.value;
+ }
+ if (value.type === "var") {
+ return undefined;
+ }
+ return undefined;
+};
+
+const compileAnimation = (animation: EventAnimation): KeyframesAndTiming => {
+ const keyframes: Keyframe[] = animation.keyframes.map((frame) => {
+ const compiled: Keyframe = {};
+ if (frame.offset !== undefined) {
+ compiled.offset = frame.offset;
+ }
+ for (const [property, value] of Object.entries(frame.styles)) {
+ compiled[property] = toValue(value);
+ }
+ return compiled;
+ });
+
+ const timing: KeyframeAnimationOptions = {
+ fill: animation.timing.fill,
+ easing: animation.timing.easing,
+ direction: animation.timing.direction,
+ iterations:
+ animation.timing.iterations === "infinite"
+ ? Infinity
+ : animation.timing.iterations,
+ playbackRate: animation.timing.playbackRate,
+ delay: convertDuration(animation.timing.delay),
+ duration: convertDuration(animation.timing.duration),
+ };
+
+ return { keyframes, timing };
+};
+
+const resolveTargets = (
+ action: AnimationActionEvent,
+ host: HTMLElement | null
+): HTMLElement[] => {
+ if (action.target === undefined || action.target === "self") {
+ // For "self" target, animate all child elements since host has display:contents
+ if (host === null) {
+ return [];
+ }
+ // Get all element children (skip text nodes)
+ const children = Array.from(host.children) as HTMLElement[];
+ return children.length > 0 ? children : [host];
+ }
+ const target =
+ typeof document !== "undefined"
+ ? (document.querySelector(
+ `[data-ws-id="${CSS.escape(action.target)}"]`
+ ) as HTMLElement | null)
+ : null;
+ return target ? [target] : [];
+};
+
+const applyCommand = ({
+ command,
+ seekTo,
+ getAnimations,
+ buildAnimations,
+ cleanup,
+}: {
+ command: AnimationActionEvent["command"];
+ seekTo: AnimationActionEvent["seekTo"];
+ getAnimations: () => Animation[];
+ buildAnimations: () => Animation[];
+ cleanup: () => void;
+}) => {
+ const animations = getAnimations();
+ const ensureAnimations = () =>
+ animations.length > 0 ? animations : buildAnimations();
+
+ const activeAnimations = ensureAnimations();
+
+ switch (command) {
+ case "play": {
+ for (const animation of activeAnimations) {
+ animation.play();
+ }
+ break;
+ }
+ case "pause": {
+ for (const animation of activeAnimations) {
+ animation.pause();
+ }
+ break;
+ }
+ case "toggle": {
+ const paused = activeAnimations.some(
+ (animation) => animation.playState === "paused"
+ );
+ for (const animation of activeAnimations) {
+ if (paused) {
+ animation.play();
+ } else {
+ animation.pause();
+ }
+ }
+ break;
+ }
+ case "restart": {
+ cleanup();
+ const fresh = buildAnimations();
+ for (const animation of fresh) {
+ animation.play();
+ }
+ break;
+ }
+ case "reverse": {
+ const targets = ensureAnimations();
+ for (const animation of targets) {
+ if (typeof animation.reverse === "function") {
+ animation.reverse();
+ } else {
+ animation.playbackRate = (animation.playbackRate ?? 1) * -1;
+ }
+ }
+ break;
+ }
+ case "seek": {
+ if (seekTo === undefined) {
+ return;
+ }
+ const targets = ensureAnimations();
+ for (const animation of targets) {
+ const duration = animation.effect?.getTiming().duration;
+ if (typeof duration === "number") {
+ animation.currentTime = duration * seekTo;
+ animation.pause();
+ }
+ }
+ break;
+ }
+ }
+};
type ScrollProps = {
debug?: boolean;
children?: React.ReactNode;
action: AnimationAction;
+ /**
+ * Instance ID used for HTML Invoker Commands targeting
+ * The component sets this as its HTML id attribute so buttons can use commandfor
+ */
+ "data-ws-id"?: string;
};
export const AnimateChildren = forwardRef, ScrollProps>(
- ({ debug = false, action, ...props }, ref) => {
- return ;
+ ({ debug = false, action, "data-ws-id": instanceId, ...props }, ref) => {
+ const localRef = useRef | null>(null);
+ const resolvedRef = (node: ElementRef<"div"> | null) => {
+ localRef.current = node;
+ if (typeof ref === "function") {
+ ref(node);
+ } else if (ref) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ ref.current = node;
+ }
+ };
+
+ const compiledAnimations = useMemo((): KeyframesAndTiming[] => {
+ if (isEventAction(action) === false) {
+ return [];
+ }
+ return action.animations.map(compileAnimation);
+ }, [action]);
+
+ // Install polyfill for HTML Invoker Commands on mount
+ useEffect(() => {
+ installInvokerPolyfill();
+ }, []);
+
+ useEffect(() => {
+ if (isEventAction(action) === false) {
+ return;
+ }
+ // Store typed action for use in closures
+ const eventAction = action;
+
+ if (isFeatureEnabled("commandAnimations") === false) {
+ return;
+ }
+ if (
+ eventAction.respectReducedMotion !== false &&
+ typeof window !== "undefined" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return;
+ }
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return;
+ }
+
+ const host = localRef.current;
+ if (host === null) {
+ return;
+ }
+
+ const targets = resolveTargets(eventAction, host);
+ if (targets.length === 0) {
+ return;
+ }
+
+ let animations: Animation[] = [];
+
+ const cleanup = () => {
+ for (const animation of animations) {
+ animation.cancel();
+ }
+ animations = [];
+ };
+
+ const buildAnimations = () => {
+ cleanup();
+ // Apply animation to all target elements
+ animations = targets.flatMap((target) =>
+ compiledAnimations.map(({ keyframes, timing }) =>
+ target.animate(keyframes, timing)
+ )
+ );
+ return animations;
+ };
+
+ const getAnimations = () => animations;
+
+ const handler = (event: Event) => {
+ // Allow animation when: isPinned is true OR debug is true OR command is "seek"
+ const canPlay =
+ eventAction.isPinned === true ||
+ debug === true ||
+ eventAction.command === "seek";
+ if (canPlay === false) {
+ return;
+ }
+ if (
+ (event.type === "keydown" || event.type === "keyup") &&
+ "key" in event &&
+ eventAction.triggers.some((trigger) => trigger.kind === event.type) &&
+ eventAction.triggers.every((trigger) => {
+ if (trigger.kind !== event.type) {
+ return true;
+ }
+ // Only keydown/keyup triggers have a key property
+ if (trigger.kind === "keydown" || trigger.kind === "keyup") {
+ if (trigger.key === undefined) {
+ return true;
+ }
+ return trigger.key === (event as KeyboardEvent).key;
+ }
+ return true;
+ }) === false
+ ) {
+ return;
+ }
+
+ applyCommand({
+ command: eventAction.command,
+ seekTo: eventAction.seekTo,
+ getAnimations,
+ buildAnimations,
+ cleanup,
+ });
+ };
+
+ const listeners: Array<[EventTarget, string, EventListener]> = [];
+
+ for (const trigger of eventAction.triggers) {
+ // Handle HTML Invoker Command triggers
+ if (trigger.kind === "command") {
+ const commandListener: EventListener = (event) => {
+ const receivedCommand = getCommandFromEvent(event);
+ // Only trigger if command matches
+ if (receivedCommand === trigger.command) {
+ handler(event);
+ }
+ };
+ // Listen for native "command" event (HTML Invoker Commands API)
+ host.addEventListener("command", commandListener);
+ listeners.push([host, "command", commandListener]);
+ continue;
+ }
+
+ // Handle standard DOM event triggers
+ const eventName = trigger.kind;
+ const listener: EventListener = (event) => {
+ // Filter by key for keydown/keyup triggers
+ if (trigger.kind === "keydown" || trigger.kind === "keyup") {
+ if (
+ trigger.key !== undefined &&
+ "key" in event &&
+ trigger.key !== (event as KeyboardEvent).key
+ ) {
+ return;
+ }
+ }
+ handler(event);
+ };
+ host.addEventListener(eventName, listener);
+ listeners.push([host, eventName, listener]);
+ }
+
+ return () => {
+ cleanup();
+ for (const [target, name, listener] of listeners) {
+ target.removeEventListener(name, listener);
+ }
+ };
+ }, [action, compiledAnimations, debug]);
+
+ // Live preview: auto-play animation when isPinned is true and animations change
+ // Note: This does NOT auto-play for Command triggers - those should only fire on user interaction
+ useEffect(() => {
+ if (isEventAction(action) === false) {
+ return;
+ }
+ // Store typed action for use in closures
+ const eventAction = action;
+
+ if (isFeatureEnabled("commandAnimations") === false) {
+ return;
+ }
+ // Only auto-play when isPinned (Run on Canvas) is enabled
+ if (eventAction.isPinned !== true) {
+ return;
+ }
+
+ // Don't auto-play for Command triggers - they should only fire on user interaction (click)
+ // Command triggers are meant to be invoked by buttons with commandfor/command attributes
+ const hasOnlyCommandTriggers = eventAction.triggers.every(
+ (trigger) => trigger.kind === "command"
+ );
+ if (hasOnlyCommandTriggers) {
+ return;
+ }
+
+ if (compiledAnimations.length === 0) {
+ return;
+ }
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return;
+ }
+
+ const host = localRef.current;
+ if (host === null) {
+ return;
+ }
+
+ const targets = resolveTargets(eventAction, host);
+ if (targets.length === 0) {
+ return;
+ }
+
+ let previewAnimations: Animation[] = [];
+
+ // Small delay to ensure DOM is ready and to debounce rapid changes
+ const timeoutId = setTimeout(() => {
+ // Reset any previous animation styles before starting new ones
+ for (const target of targets) {
+ target.getAnimations().forEach((anim) => anim.cancel());
+ }
+
+ // Apply animation to all target elements
+ previewAnimations = targets.flatMap((target) =>
+ compiledAnimations.map(({ keyframes, timing }) =>
+ target.animate(keyframes, {
+ ...timing,
+ // Use "none" fill for preview to avoid persisting end state
+ fill: "none",
+ })
+ )
+ );
+ }, 50);
+
+ return () => {
+ clearTimeout(timeoutId);
+ // Cancel all preview animations and reset element state
+ for (const animation of previewAnimations) {
+ animation.cancel();
+ }
+ // Also cancel any other animations on all targets
+ for (const target of targets) {
+ target.getAnimations().forEach((anim) => anim.cancel());
+ }
+ };
+ }, [action, compiledAnimations]);
+
+ return (
+
+ );
}
);
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 6ea6e89aabfa..51a9a9798d30 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -28,6 +28,7 @@ export type {
AnimationAction,
AnimationActionScroll,
AnimationActionView,
+ AnimationActionEvent,
AnimationKeyframe,
KeyframeStyles,
RangeUnit,
@@ -38,6 +39,11 @@ export type {
ViewRangeValue,
ScrollAnimation,
ViewAnimation,
+ EventAnimation,
+ EventTrigger,
+ EventCommand,
+ EventTriggerKind,
+ CommandString,
InsetUnitValue,
DurationUnitValue,
TimeUnit,
@@ -47,9 +53,24 @@ export {
animationActionSchema,
scrollAnimationSchema,
viewAnimationSchema,
+ eventAnimationSchema,
+ eventActionSchema,
+ eventTriggerSchema,
+ eventCommandSchema,
+ commandStringSchema,
+ isCompleteCommandString,
rangeUnitValueSchema,
animationKeyframeSchema,
insetUnitValueSchema,
durationUnitValueSchema,
RANGE_UNITS,
+ EVENT_TRIGGER_KINDS,
} from "./schema/animation-schema";
+
+// HTML Invoker Commands
+export type { Invoker } from "./schema/invoker-schema";
+export {
+ invokerSchema,
+ isValidCommand,
+ isCompleteInvoker,
+} from "./schema/invoker-schema";
diff --git a/packages/sdk/src/schema/animation-schema.test.ts b/packages/sdk/src/schema/animation-schema.test.ts
new file mode 100644
index 000000000000..add8f6c847e6
--- /dev/null
+++ b/packages/sdk/src/schema/animation-schema.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from "vitest";
+import {
+ animationActionSchema,
+ eventActionSchema,
+ eventTriggerSchema,
+ commandStringSchema,
+ isCompleteCommandString,
+} from "./animation-schema";
+
+describe("animation schemas", () => {
+ test("accepts event action with triggers and command", () => {
+ const parsed = eventActionSchema.parse({
+ type: "event",
+ target: "self",
+ triggers: [{ kind: "click" }],
+ command: "play",
+ animations: [
+ {
+ timing: { duration: { type: "unit", value: 100, unit: "ms" } },
+ keyframes: [
+ { styles: { opacity: { type: "unparsed", value: "0" } } },
+ ],
+ },
+ ],
+ });
+
+ expect(parsed.command).toBe("play");
+ expect(parsed.triggers[0]?.kind).toBe("click");
+ });
+
+ test("discriminated union still accepts scroll/view actions", () => {
+ expect(
+ animationActionSchema.parse({
+ type: "scroll",
+ animations: [],
+ })
+ ).toMatchObject({ type: "scroll" });
+
+ expect(
+ animationActionSchema.parse({
+ type: "view",
+ animations: [],
+ })
+ ).toMatchObject({ type: "view" });
+ });
+
+ describe("HTML Invoker Commands", () => {
+ test("command trigger with valid -- prefix", () => {
+ const parsed = eventTriggerSchema.parse({
+ kind: "command",
+ command: "--play-intro",
+ });
+ expect(parsed).toEqual({ kind: "command", command: "--play-intro" });
+ });
+
+ test("command trigger allows incomplete values during editing", () => {
+ // Schema allows incomplete values to support editing flow
+ const parsed = eventTriggerSchema.parse({
+ kind: "command",
+ command: "--", // Incomplete but allowed during editing
+ });
+ expect(parsed).toEqual({ kind: "command", command: "--" });
+ });
+
+ test("isCompleteCommandString validates command strings for rendering", () => {
+ // Valid complete command strings
+ expect(isCompleteCommandString("--my-command")).toBe(true);
+ expect(isCompleteCommandString("--open-modal")).toBe(true);
+ expect(isCompleteCommandString("--a")).toBe(true); // 3 chars minimum
+
+ // Invalid/incomplete command strings
+ expect(isCompleteCommandString("invalid")).toBe(false); // Missing -- prefix
+ expect(isCompleteCommandString("-single")).toBe(false); // Wrong prefix
+ expect(isCompleteCommandString("--")).toBe(false); // Too short (2 chars)
+ expect(isCompleteCommandString("")).toBe(false); // Empty
+ });
+
+ test("commandStringSchema allows any string (validation via isCompleteCommandString)", () => {
+ // Schema accepts any string for editing flexibility
+ expect(commandStringSchema.parse("--my-command")).toBe("--my-command");
+ expect(commandStringSchema.parse("--")).toBe("--");
+ expect(commandStringSchema.parse("")).toBe("");
+ });
+
+ test("event action with command trigger", () => {
+ const parsed = eventActionSchema.parse({
+ type: "event",
+ triggers: [{ kind: "command", command: "--toggle-menu" }],
+ command: "play",
+ animations: [
+ {
+ timing: { duration: { type: "unit", value: 300, unit: "ms" } },
+ keyframes: [
+ {
+ offset: 0,
+ styles: { opacity: { type: "unparsed", value: "0" } },
+ },
+ {
+ offset: 1,
+ styles: { opacity: { type: "unparsed", value: "1" } },
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(parsed.triggers[0]).toEqual({
+ kind: "command",
+ command: "--toggle-menu",
+ });
+ });
+
+ test("DOM triggers still work (click, keydown, etc.)", () => {
+ expect(eventTriggerSchema.parse({ kind: "click" })).toEqual({
+ kind: "click",
+ });
+ expect(
+ eventTriggerSchema.parse({ kind: "keydown", key: "Enter" })
+ ).toEqual({
+ kind: "keydown",
+ key: "Enter",
+ });
+ expect(eventTriggerSchema.parse({ kind: "pointerenter" })).toEqual({
+ kind: "pointerenter",
+ });
+ });
+ });
+});
diff --git a/packages/sdk/src/schema/animation-schema.ts b/packages/sdk/src/schema/animation-schema.ts
index 82d1c0036818..9199a41a9243 100644
--- a/packages/sdk/src/schema/animation-schema.ts
+++ b/packages/sdk/src/schema/animation-schema.ts
@@ -219,10 +219,122 @@ export const viewActionSchema = z.object({
debug: z.boolean().optional(),
});
+// Event Animation Timing
+export const eventAnimationTimingSchema = z.object({
+ duration: durationUnitValueSchema.optional(),
+ delay: durationUnitValueSchema.optional(),
+ fill: z
+ .union([
+ z.literal("none"),
+ z.literal("forwards"),
+ z.literal("backwards"),
+ z.literal("both"),
+ ])
+ .optional(),
+ easing: z.string().optional(),
+ direction: z
+ .union([
+ z.literal("normal"),
+ z.literal("reverse"),
+ z.literal("alternate"),
+ z.literal("alternate-reverse"),
+ ])
+ .optional(),
+ iterations: z.union([z.number(), z.literal("infinite")]).optional(),
+ playbackRate: z.number().optional(),
+});
+
+// Event Animation (for user-triggered animations like click, hover, etc.)
+export const eventAnimationSchema = baseAnimation.merge(
+ z.object({
+ timing: eventAnimationTimingSchema,
+ })
+);
+
+/**
+ * Event Trigger Kinds - DOM events that can trigger animations
+ */
+export const EVENT_TRIGGER_KINDS = [
+ "click",
+ "dblclick",
+ "pointerenter",
+ "pointerleave",
+ "focus",
+ "blur",
+ "keydown",
+ "keyup",
+] as const;
+
+export type EventTriggerKind = (typeof EVENT_TRIGGER_KINDS)[number];
+
+/**
+ * Command string validation - must start with "--" (HTML Invoker Commands standard)
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ *
+ * Note: The schema allows incomplete values (like "--") during editing.
+ * Use isCompleteCommandString() to check if a command is valid for rendering.
+ */
+export const commandStringSchema = z.string();
+
+/**
+ * Check if a command string is complete and valid for rendering
+ * Must be at least 3 characters and start with "--"
+ */
+export const isCompleteCommandString = (command: string): boolean => {
+ return command.length >= 3 && command.startsWith("--");
+};
+
+/**
+ * Event Trigger Schema - supports both DOM events and HTML Invoker Commands
+ *
+ * DOM triggers: click, pointerenter, keydown, etc.
+ * Command triggers: HTML Invoker Commands (commandfor/command attributes)
+ */
+export const eventTriggerSchema = z.discriminatedUnion("kind", [
+ // Standard DOM event triggers
+ z.object({ kind: z.literal("click") }),
+ z.object({ kind: z.literal("dblclick") }),
+ z.object({ kind: z.literal("pointerenter") }),
+ z.object({ kind: z.literal("pointerleave") }),
+ z.object({ kind: z.literal("focus") }),
+ z.object({ kind: z.literal("blur") }),
+ z.object({ kind: z.literal("keydown"), key: z.string().optional() }),
+ z.object({ kind: z.literal("keyup"), key: z.string().optional() }),
+ // HTML Invoker Command trigger - receives commands from buttons with commandfor/command
+ z.object({
+ kind: z.literal("command"),
+ command: commandStringSchema,
+ }),
+]);
+
+// Event Command Schema
+export const eventCommandSchema = z.union([
+ z.literal("play"),
+ z.literal("pause"),
+ z.literal("toggle"),
+ z.literal("restart"),
+ z.literal("reverse"),
+ z.literal("seek"),
+]);
+
+// Event Action (for command-driven animations)
+export const eventActionSchema = z.object({
+ type: z.literal("event"),
+ target: z.string().optional(), // "self" or instance ID
+ triggers: z.array(eventTriggerSchema),
+ command: eventCommandSchema,
+ seekTo: z.number().optional(), // For seek command (0-1)
+ animations: z.array(eventAnimationSchema),
+ respectReducedMotion: z.boolean().optional(),
+ isPinned: z.boolean().optional(),
+ debug: z.boolean().optional(),
+});
+
// Animation Action
export const animationActionSchema = z.discriminatedUnion("type", [
scrollActionSchema,
viewActionSchema,
+ eventActionSchema,
]);
// Helper function to check if a value is a valid range unit
@@ -244,7 +356,12 @@ export type ViewNamedRange = z.infer;
export type ViewRangeValue = z.infer;
export type AnimationActionScroll = z.infer;
export type AnimationActionView = z.infer;
+export type AnimationActionEvent = z.infer;
export type AnimationAction = z.infer;
export type ScrollAnimation = z.infer;
export type ViewAnimation = z.infer;
+export type EventAnimation = z.infer;
+export type EventTrigger = z.infer;
+export type EventCommand = z.infer;
export type InsetUnitValue = z.infer;
+export type CommandString = z.infer;
diff --git a/packages/sdk/src/schema/invoker-schema.ts b/packages/sdk/src/schema/invoker-schema.ts
new file mode 100644
index 000000000000..9a0781682961
--- /dev/null
+++ b/packages/sdk/src/schema/invoker-schema.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import { commandStringSchema } from "./animation-schema";
+
+/**
+ * HTML Invoker Commands Schema
+ *
+ * Implements the HTML Invoker Commands API for declarative button behaviors.
+ * This enables buttons to trigger animations on other elements without JavaScript.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ *
+ * HTML Output:
+ * ```html
+ *
+ * ```
+ *
+ * The target Animation Group listens for "command" events and triggers
+ * the animation when the command matches.
+ */
+
+/**
+ * Invoker prop value - defines how a button invokes commands on target elements
+ *
+ * Note: Empty values are allowed during editing. The component generator
+ * will skip rendering commandfor/command attributes if values are incomplete.
+ */
+export const invokerSchema = z.object({
+ /**
+ * Target instance ID - the Animation Group that receives this command
+ * This becomes the HTML `commandfor` attribute value (referencing the element's id)
+ * Empty string means not yet configured
+ */
+ targetInstanceId: z.string(),
+
+ /**
+ * Command name with -- prefix (HTML Invoker Commands standard)
+ * Custom commands must start with "--" to avoid conflicts with built-in commands
+ * Examples: "--play-intro", "--toggle-menu", "--show-modal"
+ * "--" alone means not yet configured
+ */
+ command: z.string(),
+});
+
+/**
+ * Check if an invoker value is complete and valid for rendering
+ */
+export const isCompleteInvoker = (invoker: Invoker): boolean => {
+ return (
+ invoker.targetInstanceId.length >= 1 &&
+ invoker.command.length >= 3 &&
+ invoker.command.startsWith("--")
+ );
+};
+
+export type Invoker = z.infer;
+
+/**
+ * Check if a command is a valid HTML Invoker custom command
+ */
+export const isValidCommand = (command: string): boolean => {
+ return commandStringSchema.safeParse(command).success;
+};
diff --git a/packages/sdk/src/schema/prop-meta.ts b/packages/sdk/src/schema/prop-meta.ts
index 96c8a7877bb1..7e194285a050 100644
--- a/packages/sdk/src/schema/prop-meta.ts
+++ b/packages/sdk/src/schema/prop-meta.ts
@@ -189,6 +189,17 @@ const AnimationAction = z.object({
defaultValue: z.undefined().optional(),
});
+/**
+ * HTML Invoker Commands - enables declarative button-to-animation connections
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API
+ */
+const Invoker = z.object({
+ ...common,
+ control: z.literal("invoker"),
+ type: z.literal("invoker"),
+ defaultValue: z.undefined().optional(),
+});
+
export const PropMeta = z.union([
Tag,
Number,
@@ -212,6 +223,7 @@ export const PropMeta = z.union([
Action,
TextContent,
AnimationAction,
+ Invoker,
]);
export type PropMeta = z.infer;
diff --git a/packages/sdk/src/schema/props.ts b/packages/sdk/src/schema/props.ts
index a17cbf521242..aa9ef6bded57 100644
--- a/packages/sdk/src/schema/props.ts
+++ b/packages/sdk/src/schema/props.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { animationActionSchema } from "./animation-schema";
+import { invokerSchema } from "./invoker-schema";
const PropId = z.string();
@@ -86,6 +87,12 @@ export const Prop = z.union([
type: z.literal("animationAction"),
value: animationActionSchema,
}),
+ // HTML Invoker Commands - enables buttons to trigger animations on other elements
+ z.object({
+ ...baseProp,
+ type: z.literal("invoker"),
+ value: invokerSchema,
+ }),
]);
export type Prop = z.infer;