From 890fd8eb456318819afcfbb047ff787f8f6b224b Mon Sep 17 00:00:00 2001 From: vianm Date: Thu, 2 Jul 2026 02:01:12 +0200 Subject: [PATCH] feat: add LottiePlayer community component Adds a new LottiePlayer component that renders Lottie animations from a JSON URL. Supports three behaviors: - loop: plays and loops continuously, with optional reverse direction - toggle on click: click to toggle between open (last frame) and closed (first frame) - toggle on hover: plays forward on hover, reverses on mouse out Also supports a controlled mode via the isOpen prop to share state with other elements. Co-Authored-By: Claude Sonnet 4.6 --- .../settings-panel/controls/select-asset.tsx | 44 ++- .../shared/asset-manager/asset-thumbnail.tsx | 14 +- .../shared/asset-manager/lottie-thumbnail.tsx | 77 ++++++ apps/builder/package.json | 9 +- packages/icons/icons/lottie-player.svg | 4 + .../icons/src/__generated__/components.tsx | 35 +++ packages/icons/src/__generated__/svg.ts | 2 + packages/sdk-components-react/package.json | 1 + .../src/__generated__/lottie-player.props.ts | 45 ++++ .../sdk-components-react/src/components.ts | 1 + .../src/lottie-player.template.tsx | 18 ++ .../src/lottie-player.tsx | 254 ++++++++++++++++++ .../src/lottie-player.ws.ts | 53 ++++ packages/sdk-components-react/src/metas.ts | 1 + .../sdk-components-react/src/templates.ts | 1 + pnpm-lock.yaml | 11 + 16 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 apps/builder/app/builder/shared/asset-manager/lottie-thumbnail.tsx create mode 100644 packages/icons/icons/lottie-player.svg create mode 100644 packages/sdk-components-react/src/__generated__/lottie-player.props.ts create mode 100644 packages/sdk-components-react/src/lottie-player.template.tsx create mode 100644 packages/sdk-components-react/src/lottie-player.tsx create mode 100644 packages/sdk-components-react/src/lottie-player.ws.ts diff --git a/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx b/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx index 35171333d73e..768d7c93fcfb 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx @@ -1,22 +1,41 @@ import { useMemo } from "react"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; -import { Button, Flex, Text, FloatingPanel } from "@webstudio-is/design-system"; +import { Button, Flex, FloatingPanel } from "@webstudio-is/design-system"; import type { Prop } from "@webstudio-is/sdk"; import { acceptToMimeCategories } from "@webstudio-is/sdk"; +import type { AssetType } from "@webstudio-is/asset-uploader"; import { $assets } from "~/shared/sync/data-stores"; import { AssetManager } from "~/builder/shared/asset-manager"; import { type ControlProps } from "../shared"; import { formatAssetName } from "~/builder/shared/assets/asset-utils"; import { AssetUpload } from "~/builder/shared/assets"; -// tests whether we can use AssetManager for the given "accept" value -const isImageAccept = (accept?: string) => { - const acceptCategories = acceptToMimeCategories(accept || ""); - return ( - acceptCategories === "*" || - (acceptCategories.size === 1 && acceptCategories.has("image")) - ); +const getUploadType = (accept?: string): AssetType => { + const categories = acceptToMimeCategories(accept ?? ""); + if (categories === "*" || categories.has("image")) { + return "image"; + } + if (categories.has("font")) { + return "font"; + } + if (categories.has("video")) { + return "video"; + } + return "file"; +}; + +const getPanelTitle = (uploadType: AssetType): string => { + if (uploadType === "image") { + return "Images"; + } + if (uploadType === "font") { + return "Fonts"; + } + if (uploadType === "video") { + return "Videos"; + } + return "Files"; }; type AssetControlProps = ControlProps; @@ -37,16 +56,13 @@ export const SelectAsset = ({ prop, onChange, accept }: Props) => { ); const asset = useStore($asset); - - if (isImageAccept(accept) === false) { - return Unsupported accept value: {accept}; - } + const uploadType = getUploadType(accept); return ( } + title={getPanelTitle(uploadType)} + titleSuffix={} content={ onChange({ type: "asset", value: assetId })} diff --git a/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx b/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx index 6eb2b8da7e15..89055cc83209 100644 --- a/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx +++ b/apps/builder/app/builder/shared/asset-manager/asset-thumbnail.tsx @@ -6,6 +6,7 @@ import { UploadingAnimation } from "./uploading-animation"; import { AssetInfo, assetInfoCssVars } from "./asset-info"; import type { AssetContainer } from "~/builder/shared/assets"; import { Image } from "./image"; +import { LottieThumbnail } from "./lottie-thumbnail"; import brokenImage from "~/shared/images/broken-image-placeholder.svg"; import { theme } from "@webstudio-is/design-system"; import { @@ -134,7 +135,7 @@ const Thumbnail = styled(Box, { justifyContent: "center", }); -const GenericFilePreview = ({ +export const GenericFilePreview = ({ ext, format, }: { @@ -233,6 +234,17 @@ export const AssetThumbnail = ({ : wsVideoLoader({ src: asset.name }) } /> + ) : asset.format === "json" ? ( + // JSON files — render first frame of Lottie animation + ) : ( // Other files - show icon based on category diff --git a/apps/builder/app/builder/shared/asset-manager/lottie-thumbnail.tsx b/apps/builder/app/builder/shared/asset-manager/lottie-thumbnail.tsx new file mode 100644 index 000000000000..90c3cab47f8d --- /dev/null +++ b/apps/builder/app/builder/shared/asset-manager/lottie-thumbnail.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { Box } from "@webstudio-is/design-system"; +import { GenericFilePreview } from "./asset-thumbnail"; + +type Props = { + src: string; + ext: string; + format: string; +}; + +export const LottieThumbnail = ({ src, ext, format }: Props) => { + const containerRef = useRef(null); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + let cancelled = false; + + import("lottie-web").then(({ default: lottie }) => { + if (cancelled || !container.isConnected) { + return; + } + + const anim = lottie.loadAnimation({ + container, + renderer: "svg", + loop: false, + autoplay: false, + path: src, + }); + + anim.addEventListener("data_ready", () => { + if (!cancelled) { + anim.goToAndStop(0, true); + } + }); + + anim.addEventListener("data_failed", () => { + if (!cancelled) { + setHasError(true); + } + }); + + return () => { + anim.destroy(); + }; + }); + + return () => { + cancelled = true; + container.innerHTML = ""; + }; + }, [src]); + + if (hasError) { + return ; + } + + return ( + + ); +}; diff --git a/apps/builder/package.json b/apps/builder/package.json index 3f30ee4affed..7853659bdb84 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -63,7 +63,6 @@ "@tus/server": "^2.4.1", "@unocss/core": "66.6.8", "@vercel/remix": "2.15.3", - "@webstudio-is/protocol": "workspace:*", "@webstudio-is/asset-uploader": "workspace:*", "@webstudio-is/authorization-token": "workspace:*", "@webstudio-is/css-data": "workspace:*", @@ -71,7 +70,6 @@ "@webstudio-is/dashboard": "workspace:*", "@webstudio-is/design-system": "workspace:*", "@webstudio-is/domain": "workspace:*", - "@webstudio-is/sync-client": "workspace:*", "@webstudio-is/feature-flags": "workspace:*", "@webstudio-is/fonts": "workspace:*", "@webstudio-is/html-data": "workspace:*", @@ -83,13 +81,16 @@ "@webstudio-is/project": "workspace:*", "@webstudio-is/project-build": "workspace:*", "@webstudio-is/project-migrations": "workspace:*", + "@webstudio-is/protocol": "workspace:*", "@webstudio-is/react-sdk": "workspace:*", "@webstudio-is/sdk": "workspace:*", "@webstudio-is/sdk-components-animation": "workspace:*", "@webstudio-is/sdk-components-react": "workspace:*", "@webstudio-is/sdk-components-react-radix": "workspace:*", + "@webstudio-is/sync-client": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/trpc-interface": "workspace:*", + "@webstudio-is/wsauth": "workspace:*", "args-tokenizer": "^0.3.0", "bcp-47": "^2.1.0", "change-case": "^5.4.4", @@ -105,6 +106,7 @@ "immerhin": "^0.10.0", "isbot": "^5.1.25", "lexical": "^0.21.0", + "lottie-web": "^5.12.2", "match-sorter": "^8.0.0", "micromark": "^4.0.2", "micromark-extension-gfm": "^3.0.0", @@ -133,8 +135,7 @@ "valid-filename": "^4.0.0", "warn-once": "^0.1.1", "zod": "^3.24.2", - "zod-validation-error": "^3.4.0", - "@webstudio-is/wsauth": "workspace:*" + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@remix-run/dev": "^2.16.5", diff --git a/packages/icons/icons/lottie-player.svg b/packages/icons/icons/lottie-player.svg new file mode 100644 index 000000000000..c31fbb9e06a1 --- /dev/null +++ b/packages/icons/icons/lottie-player.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/__generated__/components.tsx b/packages/icons/src/__generated__/components.tsx index 74b895fd105d..82375bc65d28 100644 --- a/packages/icons/src/__generated__/components.tsx +++ b/packages/icons/src/__generated__/components.tsx @@ -3663,6 +3663,41 @@ export const LockIcon: IconComponent = forwardRef( ); LockIcon.displayName = "LockIcon"; +export const LottiePlayerIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + + ); + } +); +LottiePlayerIcon.displayName = "LottiePlayerIcon"; + export const MarkdownEmbedIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( diff --git a/packages/icons/src/__generated__/svg.ts b/packages/icons/src/__generated__/svg.ts index 4f369bd0b179..535a05ce43e3 100644 --- a/packages/icons/src/__generated__/svg.ts +++ b/packages/icons/src/__generated__/svg.ts @@ -272,6 +272,8 @@ export const LoadingDotsIcon = ``; +export const LottiePlayerIcon = ``; + export const MarkdownEmbedIcon = ``; export const MaximizeIcon = ``; diff --git a/packages/sdk-components-react/package.json b/packages/sdk-components-react/package.json index 78c916159775..c200b6d52ddf 100644 --- a/packages/sdk-components-react/package.json +++ b/packages/sdk-components-react/package.json @@ -58,6 +58,7 @@ "@webstudio-is/react-sdk": "workspace:*", "@webstudio-is/sdk": "workspace:*", "await-interaction-response": "^0.0.2", + "lottie-web": "^5.12.2", "colord": "^2.9.3", "micromark": "^4.0.2", "micromark-extension-gfm-table": "^2.1.1" diff --git a/packages/sdk-components-react/src/__generated__/lottie-player.props.ts b/packages/sdk-components-react/src/__generated__/lottie-player.props.ts new file mode 100644 index 000000000000..2f1bc895c4f9 --- /dev/null +++ b/packages/sdk-components-react/src/__generated__/lottie-player.props.ts @@ -0,0 +1,45 @@ +import type { PropMeta } from "@webstudio-is/sdk"; + +export const props: Record = { + behavior: { + description: "loop, toggle on click, or toggle on hover.", + required: false, + control: "radio", + type: "string", + options: ["loop", "toggle on click", "toggle on hover"], + defaultValue: "loop", + }, + playReverse: { + description: "Play the animation in reverse (loop mode only).", + required: false, + control: "boolean", + type: "boolean", + defaultValue: false, + }, + speed: { + description: "Playback speed. 1 = normal, 2 = double.", + required: false, + control: "number", + type: "number", + defaultValue: 1, + }, + previewOnCanvas: { + description: "Play the animation on the canvas in the builder.", + required: false, + control: "boolean", + type: "boolean", + defaultValue: false, + }, + src: { + description: "URL to a Lottie JSON file.", + required: false, + control: "text", + type: "string", + }, + isOpen: { + description: "Controlled open/closed state for toggle and hover modes.", + required: false, + control: "boolean", + type: "boolean", + }, +}; diff --git a/packages/sdk-components-react/src/components.ts b/packages/sdk-components-react/src/components.ts index 33596b54cfde..c318e2f58c8f 100644 --- a/packages/sdk-components-react/src/components.ts +++ b/packages/sdk-components-react/src/components.ts @@ -30,6 +30,7 @@ export { RadioButton } from "./radio-button"; export { Checkbox } from "./checkbox"; export { Vimeo } from "./vimeo"; export { YouTube } from "./youtube"; +export { LottiePlayer } from "./lottie-player"; export { VimeoPreviewImage } from "./vimeo-preview-image"; export { VimeoPlayButton } from "./vimeo-play-button"; export { VimeoSpinner } from "./vimeo-spinner"; diff --git a/packages/sdk-components-react/src/lottie-player.template.tsx b/packages/sdk-components-react/src/lottie-player.template.tsx new file mode 100644 index 000000000000..c743172386a4 --- /dev/null +++ b/packages/sdk-components-react/src/lottie-player.template.tsx @@ -0,0 +1,18 @@ +import { type TemplateMeta, $, css } from "@webstudio-is/template"; + +export const meta: TemplateMeta = { + label: "Lottie Animation", + category: "media", + order: 3, + description: + "Add a Lottie JSON animation to your page. Paste a public URL to your .json animation file in the Settings tab.", + template: ( + <$.LottiePlayer + ws:label="Lottie Animation" + ws:style={css` + width: 200px; + height: 200px; + `} + /> + ), +}; diff --git a/packages/sdk-components-react/src/lottie-player.tsx b/packages/sdk-components-react/src/lottie-player.tsx new file mode 100644 index 000000000000..7d623c183fa1 --- /dev/null +++ b/packages/sdk-components-react/src/lottie-player.tsx @@ -0,0 +1,254 @@ +import { + forwardRef, + useCallback, + useContext, + useEffect, + useRef, + type ElementRef, + type ComponentProps, +} from "react"; +import { mergeRefs } from "@react-aria/utils"; +import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime"; + +const defaultTag = "div"; + +type AnimationInstance = { + destroy: () => void; + setSpeed: (speed: number) => void; + setDirection: (direction: 1 | -1) => void; + playSegments: (segments: [number, number], force?: boolean) => void; + goToAndStop: (value: number, isFrame?: boolean) => void; + totalFrames: number; + addEventListener: (name: string, callback: () => void) => void; +}; + +type Props = ComponentProps & { + /** + * URL to the Lottie animation JSON file. + * Accepts any public URL, including Webstudio asset URLs. + */ + src?: string; + + /** + * Playback speed multiplier. 1 is normal speed, 2 is double speed. + * @default 1 + */ + speed?: number; + + /** + * Play the animation in reverse. Only applies in loop mode. + * @default false + */ + playReverse?: boolean; + + /** + * Allow the animation to play directly on the canvas in the builder. + * @default false + */ + previewOnCanvas?: boolean; + + /** + * How the animation behaves: + * - "loop": plays and loops continuously. + * - "toggle on click": click to toggle between open and closed. + * - "toggle on hover": plays forward on hover, reverses on mouse out. + * @default "loop" + */ + behavior?: "loop" | "toggle on click" | "toggle on hover"; + + /** + * Controlled open/closed state for toggle behavior. + * When defined, overrides the internal state. Bind to a variable + * when the animation state needs to be shared with other elements. + */ + isOpen?: boolean; + + // lottie fills the container — children are not supported + children?: never; +}; + +type Ref = ElementRef; + +export const LottiePlayer = forwardRef( + ( + { + src, + speed = 1, + playReverse = false, + previewOnCanvas = false, + behavior = "loop", + isOpen, + children: _children, + ...rest + }, + ref + ) => { + const containerRef = useRef(null); + const animRef = useRef(null); + const totalFramesRef = useRef(0); + // Tracks open state directly without React re-renders + const openStateRef = useRef(isOpen ?? false); + + const isLoopMode = behavior === "loop"; + const isToggleMode = behavior === "toggle on click"; + const isHoverMode = behavior === "toggle on hover"; + const isControlledMode = isToggleMode || isHoverMode; + + const { renderer } = useContext(ReactSdkContext); + + const playSegment = useCallback((open: boolean) => { + const anim = animRef.current; + const total = totalFramesRef.current; + if (!anim || total <= 0) { + return; + } + const lastFrame = total - 1; + anim.playSegments(open ? [0, lastFrame] : [lastFrame, 0], true); + }, []); + + // Create / destroy the animation when source or display settings change + useEffect(() => { + const container = containerRef.current; + + if (!container || !src || (renderer === "canvas" && !previewOnCanvas)) { + return; + } + + let cancelled = false; + + import("lottie-web").then(({ default: lottie }) => { + if (cancelled || !container.isConnected) { + return; + } + + const anim = lottie.loadAnimation({ + container, + renderer: "svg", + loop: isLoopMode, + autoplay: isLoopMode, + path: src, + }) as AnimationInstance; + + animRef.current = anim; + anim.setSpeed(speed); + anim.setDirection(playReverse ? -1 : 1); + + anim.addEventListener("data_ready", () => { + if (cancelled) { + return; + } + totalFramesRef.current = anim.totalFrames; + if (isControlledMode) { + const lastFrame = anim.totalFrames - 1; + anim.goToAndStop(openStateRef.current ? lastFrame : 0, true); + } + }); + }); + + return () => { + cancelled = true; + animRef.current?.destroy(); + animRef.current = null; + totalFramesRef.current = 0; + }; + }, [ + src, + speed, + playReverse, + renderer, + previewOnCanvas, + behavior, + isLoopMode, + isControlledMode, + ]); + + // Handle external isOpen prop changes (controlled mode) + useEffect(() => { + if (!isControlledMode || isOpen === undefined) { + return; + } + openStateRef.current = isOpen; + playSegment(isOpen); + }, [isOpen, isControlledMode, playSegment]); + + if (!src) { + return ( +
+ Open the "Settings" panel and paste a Lottie animation URL +
+ ); + } + + if (renderer === "canvas" && !previewOnCanvas) { + return ( +
+ Lottie Animation + {src} +
+ ); + } + + const handleClick = + isToggleMode && isOpen === undefined + ? () => { + const newOpen = !openStateRef.current; + openStateRef.current = newOpen; + playSegment(newOpen); + } + : rest.onClick; + + const handleMouseEnter = isHoverMode + ? () => { + openStateRef.current = true; + playSegment(true); + } + : rest.onMouseEnter; + + const handleMouseLeave = isHoverMode + ? () => { + openStateRef.current = false; + playSegment(false); + } + : rest.onMouseLeave; + + return ( +
+ ); + } +); + +LottiePlayer.displayName = "LottiePlayer"; diff --git a/packages/sdk-components-react/src/lottie-player.ws.ts b/packages/sdk-components-react/src/lottie-player.ws.ts new file mode 100644 index 000000000000..b2d9120f0d99 --- /dev/null +++ b/packages/sdk-components-react/src/lottie-player.ws.ts @@ -0,0 +1,53 @@ +import type { ComponentProps } from "react"; +import { LottiePlayerIcon } from "@webstudio-is/icons/svg"; +import type { WsComponentMeta } from "@webstudio-is/sdk"; +import { div } from "@webstudio-is/sdk/normalize.css"; +import { props } from "./__generated__/lottie-player.props"; +import type { LottiePlayer } from "./lottie-player"; + +const initialProps: Array> = [ + "id", + "className", + "src", + "previewOnCanvas", + "behavior", + "speed", + "playReverse", +]; + +export const meta: WsComponentMeta = { + icon: LottiePlayerIcon, + contentModel: { + category: "instance", + children: [], + }, + presetStyle: { div }, + initialProps, + props: { + ...props, + behavior: { + ...props.behavior, + label: "Behavior", + }, + playReverse: { + ...props.playReverse, + label: "Play reverse", + }, + isOpen: { + ...props.isOpen, + label: "Is open", + }, + previewOnCanvas: { + ...props.previewOnCanvas, + label: "Play on canvas", + }, + src: { + type: "string", + control: "file", + label: "Source", + required: false, + accept: "application/json", + contentMode: true, + }, + }, +}; diff --git a/packages/sdk-components-react/src/metas.ts b/packages/sdk-components-react/src/metas.ts index 78ba385abe84..0ff7b72ba2d1 100644 --- a/packages/sdk-components-react/src/metas.ts +++ b/packages/sdk-components-react/src/metas.ts @@ -30,6 +30,7 @@ export { meta as RadioButton } from "./radio-button.ws"; export { meta as Checkbox } from "./checkbox.ws"; export { meta as Vimeo } from "./vimeo.ws"; export { meta as YouTube } from "./youtube.ws"; +export { meta as LottiePlayer } from "./lottie-player.ws"; export { meta as VimeoPreviewImage } from "./vimeo-preview-image.ws"; export { meta as VimeoPlayButton } from "./vimeo-play-button.ws"; export { meta as VimeoSpinner } from "./vimeo-spinner.ws"; diff --git a/packages/sdk-components-react/src/templates.ts b/packages/sdk-components-react/src/templates.ts index 48c3633a5823..949be6dfcfca 100644 --- a/packages/sdk-components-react/src/templates.ts +++ b/packages/sdk-components-react/src/templates.ts @@ -3,4 +3,5 @@ export { meta as MarkdownEmbed } from "./markdown-embed.template"; export { meta as Form } from "./webhook-form.template"; export { meta as Vimeo } from "./vimeo.template"; export { meta as YouTube } from "./youtube.template"; +export { meta as LottiePlayer } from "./lottie-player.template"; export { meta as HeadSlot } from "./head-slot.template"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1280bc708b83..930956ac6989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,9 @@ importers: lexical: specifier: ^0.21.0 version: 0.21.0 + lottie-web: + specifier: ^5.12.2 + version: 5.13.0 match-sorter: specifier: ^8.0.0 version: 8.0.0 @@ -2222,6 +2225,9 @@ importers: colord: specifier: ^2.9.3 version: 2.9.3 + lottie-web: + specifier: ^5.12.2 + version: 5.13.0 micromark: specifier: ^4.0.2 version: 4.0.2 @@ -7766,6 +7772,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lottie-web@5.13.0: + resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -15817,6 +15826,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lottie-web@5.13.0: {} + loupe@3.1.3: {} lru-cache@10.0.0: {}