diff --git a/src/core/components/settings/settings-panel.tsx b/src/core/components/settings/settings-panel.tsx
index 556dc852..c884f414 100644
--- a/src/core/components/settings/settings-panel.tsx
+++ b/src/core/components/settings/settings-panel.tsx
@@ -11,6 +11,7 @@ import {
useSelectedStylingBlocks,
} from "@/core/hooks";
import { PERMISSIONS, usePermissions } from "@/core/main";
+import AnimationField from "@/render/animation/animation-field";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/shadcn/components/ui/tabs";
import { ChevronDownIcon, MixerHorizontalIcon } from "@radix-ui/react-icons";
import { isEmpty, isNull, noop } from "lodash-es";
@@ -28,17 +29,17 @@ function BlockAttributesToggle() {
return null;
}
return (
- <>
+
setShowAttributes(!showAttributes)}
- className="flex cursor-pointer items-center justify-between border-t border-border py-3 text-xs font-medium hover:underline">
+ className={`flex cursor-pointer items-center justify-between p-2 text-xs font-medium hover:bg-blue-50`}>
{t("Attributes")}
{showAttributes &&
}
- >
+
);
}
@@ -128,6 +129,7 @@ const SettingsPanel: React.FC = () => {
+
@@ -171,6 +173,7 @@ const SettingsPanel: React.FC = () => {
value="styles"
className="no-scrollbar h-full max-h-min max-w-full overflow-y-auto overflow-x-hidden">
+
diff --git a/src/core/main/index.ts b/src/core/main/index.ts
index f4e764f8..436edc5a 100644
--- a/src/core/main/index.ts
+++ b/src/core/main/index.ts
@@ -29,6 +29,7 @@ export { BlockAttributesEditor as ChaiBlockAttributesEditor } from "@/core/compo
export { DefaultChaiBlocks as ChaiDefaultBlocks } from "@/core/components/sidepanels/panels/add-blocks/default-blocks";
export { ChaiDraggableBlock } from "@/core/components/sidepanels/panels/add-blocks/draggable-block";
export { ExportCodeModal as ChaiExportCodeModal } from "@/core/modals/export-code-modal";
+export { default as ChaiAnimationContainer } from "@/render/animation/animation-container";
export {
AddBlocksPanel as ChaiAddBlocksPanel,
BlockPropsEditor as ChaiBlockPropsEditor,
@@ -37,42 +38,42 @@ export {
ImportHTML as ChaiImportHTML,
Outline as ChaiOutline,
ThemeConfigPanel as ChaiThemeConfigPanel,
- UILibrariesPanel as ChaiUILibrariesPanel
+ UILibrariesPanel as ChaiUILibrariesPanel,
};
// i18n
- export { i18n };
+export { i18n };
// helper functions
- export { generateUUID as generateBlockId, cn as mergeClasses } from "@/core/functions/common-functions";
- export { getClassValueAndUnit } from "@/core/functions/helper-fn";
- export { getBlocksFromHTML as convertHTMLToChaiBlocks, getBlocksFromHTML } from "@/core/import-html/html-to-json";
+export { generateUUID as generateBlockId, cn as mergeClasses } from "@/core/functions/common-functions";
+export { getClassValueAndUnit } from "@/core/functions/helper-fn";
+export { getBlocksFromHTML as convertHTMLToChaiBlocks, getBlocksFromHTML } from "@/core/import-html/html-to-json";
// types
export type { ChaiBlock, ChaiBuilderEditorProps };
// registration apis
- export { registerChaiAddBlockTab } from "@/core/extensions/add-block-tabs";
- export {
- registerBlockSettingField,
- registerBlockSettingTemplate,
- registerBlockSettingWidget
- } from "@/core/extensions/blocks-settings";
- export { registerChaiPreImportHTMLHook } from "@/core/extensions/import-html-pre-hook";
- export { registerChaiLibrary } from "@/core/extensions/libraries";
- export { registerChaiMediaManager } from "@/core/extensions/media-manager";
- export { registerChaiSaveToLibrary } from "@/core/extensions/save-to-library";
- export { registerChaiSidebarPanel } from "@/core/extensions/sidebar-panels";
- export { registerChaiTopBar } from "@/core/extensions/top-bar";
- export {
- IfChaiFeatureFlag,
- registerChaiFeatureFlag,
- registerChaiFeatureFlags,
- useChaiFeatureFlag,
- useChaiFeatureFlags,
- useToggleChaiFeatureFlag
- } from "@/core/flags/register-chai-flag";
- export type { ChaiLibrary, ChaiLibraryBlock } from "@/types/chaibuilder-editor-props";
+export { registerChaiAddBlockTab } from "@/core/extensions/add-block-tabs";
+export {
+ registerBlockSettingField,
+ registerBlockSettingTemplate,
+ registerBlockSettingWidget,
+} from "@/core/extensions/blocks-settings";
+export { registerChaiPreImportHTMLHook } from "@/core/extensions/import-html-pre-hook";
+export { registerChaiLibrary } from "@/core/extensions/libraries";
+export { registerChaiMediaManager } from "@/core/extensions/media-manager";
+export { registerChaiSaveToLibrary } from "@/core/extensions/save-to-library";
+export { registerChaiSidebarPanel } from "@/core/extensions/sidebar-panels";
+export { registerChaiTopBar } from "@/core/extensions/top-bar";
+export {
+ IfChaiFeatureFlag,
+ registerChaiFeatureFlag,
+ registerChaiFeatureFlags,
+ useChaiFeatureFlag,
+ useChaiFeatureFlags,
+ useToggleChaiFeatureFlag,
+} from "@/core/flags/register-chai-flag";
+export type { ChaiLibrary, ChaiLibraryBlock } from "@/types/chaibuilder-editor-props";
// hooks
export { useMediaManagerComponent } from "@/core/extensions/media-manager";
@@ -82,4 +83,3 @@ export * from "@/core/hooks";
// constants
export { PERMISSIONS } from "@/core/constants/PERMISSIONS";
export type { ChaiThemeValues } from "@/types/chaibuilder-editor-props";
-
diff --git a/src/render/animation/animation-container.tsx b/src/render/animation/animation-container.tsx
new file mode 100644
index 00000000..d67fc73c
--- /dev/null
+++ b/src/render/animation/animation-container.tsx
@@ -0,0 +1,9 @@
+import "./animation-styles.css";
+import { useChaiAnimation } from "./use-chai-animation";
+
+const AnimationContainer = ({ children }) => {
+ useChaiAnimation();
+ return children;
+};
+
+export default AnimationContainer;
diff --git a/src/render/animation/animation-field.tsx b/src/render/animation/animation-field.tsx
new file mode 100644
index 00000000..b51c3638
--- /dev/null
+++ b/src/render/animation/animation-field.tsx
@@ -0,0 +1,272 @@
+import {
+ useBuilderProp,
+ useSelectedBlock,
+ useSelectedStylingBlocks,
+ useTranslation,
+ useUpdateBlocksProps,
+} from "@/core/hooks";
+import {
+ ArrowDownIcon,
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ ChevronDownIcon,
+ HeightIcon,
+ PlusIcon,
+ TrashIcon,
+} from "@radix-ui/react-icons";
+import { debounce, get, isEmpty } from "lodash-es";
+import * as React from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ANIMATION_TYPES,
+ convertToAnimationObject,
+ convertToAnimationString,
+ DEFAULT_ANIMATION,
+ EASING_OPTIONS,
+ getDirectionOptions,
+ TAnimation,
+} from "./animation-utils";
+
+const AnimationField = React.memo(() => {
+ const { t } = useTranslation();
+ const block = useSelectedBlock();
+ const [selectedStylingBlock] = useSelectedStylingBlocks();
+ const updateBlockProps = useUpdateBlocksProps();
+
+ const attrKey = `${get(selectedStylingBlock, "0.prop")}_attrs`;
+ const animationValue = get(block, `${attrKey}.data-animation`, "") as string;
+ const [show, setShow] = useState(animationValue?.length > 0);
+
+ const formData = useMemo(() => convertToAnimationObject(animationValue), [animationValue]);
+ const [localData, setLocalData] = useState
(formData);
+
+ useEffect(() => {
+ setLocalData(formData);
+ }, [formData]);
+
+ const updateAnimation = useCallback(
+ (value: string) => {
+ const currentAttrs = get(block, attrKey, {}) as Record;
+ const newAttrs = { ...currentAttrs, "data-animation": value };
+ if (!value) {
+ delete newAttrs["data-animation"];
+ }
+ updateBlockProps([get(block, "_id")], { [attrKey]: newAttrs });
+ setShow(true);
+ },
+ [block, updateBlockProps, attrKey],
+ );
+
+ const debouncedOnChange = useMemo(
+ () => debounce((v: TAnimation) => updateAnimation(convertToAnimationString(v)), 300),
+ [updateAnimation],
+ );
+
+ const handleChange = useCallback(
+ (updates: any) => {
+ const newValue = { ...localData, ...updates };
+ setLocalData(newValue);
+ debouncedOnChange(newValue);
+ },
+ [localData, debouncedOnChange],
+ );
+
+ if (isEmpty(animationValue)) {
+ return (
+
+
+
{t("Click to add reveal animation")}
+
+ );
+ }
+
+ return (
+
+
setShow(!show)}
+ className={`flex cursor-pointer items-center justify-between p-2 text-xs font-medium hover:bg-blue-50`}>
+ {t("Animation")}
+
+
+
+
+ {show && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+});
+
+const AnimationFieldWrapper = () => {
+ const animationEnabled = useBuilderProp("flags.animation", false);
+ return animationEnabled ? : null;
+};
+
+export default AnimationFieldWrapper;
diff --git a/src/render/animation/animation-styles.css b/src/render/animation/animation-styles.css
new file mode 100644
index 00000000..5269109a
--- /dev/null
+++ b/src/render/animation/animation-styles.css
@@ -0,0 +1,488 @@
+/**
+ * Reveal Animation Styles
+ * SEO-friendly: Content is always in DOM, only visual properties change
+ * Performance optimized: Uses transform and opacity (GPU accelerated)
+ * No external library dependencies
+ */
+
+/* Base state - hidden but accessible to screen readers and crawlers */
+.chai-reveal {
+ will-change: transform, opacity;
+}
+
+.chai-reveal[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+}
+
+.chai-reveal[data-visible="true"] {
+ opacity: 1;
+}
+
+/* ============================================
+ TRANSITION PROPERTY FOR SMOOTH ANIMATIONS
+ ============================================ */
+
+.chai-reveal[data-animate="true"] {
+ transition-property: opacity, transform;
+ transition-duration: var(--chai-animation-duration, 0.6s);
+ transition-timing-function: var(--chai-animation-easing, ease-out);
+ transition-delay: var(--chai-animation-delay, 0s);
+}
+
+/* ============================================
+ REDUCED MOTION SUPPORT (Accessibility)
+ ============================================ */
+
+@media (prefers-reduced-motion: reduce) {
+ .chai-reveal,
+ .chai-reveal[data-animate="true"],
+ .chai-reveal[data-visible="true"] {
+ transition: none !important;
+ animation: none !important;
+ transform: none !important;
+ opacity: 1 !important;
+ }
+}
+
+/* ============================================
+ EASING FUNCTIONS
+ ============================================ */
+
+.chai-reveal--ease-linear {
+ --chai-animation-easing: linear;
+}
+
+.chai-reveal--ease-in {
+ --chai-animation-easing: ease-in;
+}
+
+.chai-reveal--ease-out {
+ --chai-animation-easing: ease-out;
+}
+
+.chai-reveal--ease-in-out {
+ --chai-animation-easing: ease-in-out;
+}
+
+.chai-reveal--ease-in-back {
+ --chai-animation-easing: cubic-bezier(0.6, -0.28, 0.735, 0.045);
+}
+
+.chai-reveal--ease-out-back {
+ --chai-animation-easing: cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+.chai-reveal--ease-in-out-back {
+ --chai-animation-easing: cubic-bezier(0.68, -0.55, 0.265, 1.55);
+}
+
+/* ============================================
+ FADE ANIMATIONS
+ ============================================ */
+
+/* Fade In */
+.chai-reveal--fade-in[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+}
+
+.chai-reveal--fade-in[data-visible="true"] {
+ opacity: 1;
+}
+
+/* Fade In Up */
+.chai-reveal--fade-in-up[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(30px);
+}
+
+.chai-reveal--fade-in-up[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Fade In Down */
+.chai-reveal--fade-in-down[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(-30px);
+}
+
+.chai-reveal--fade-in-down[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Fade In Left */
+.chai-reveal--fade-in-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(-30px);
+}
+
+.chai-reveal--fade-in-left[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* Fade In Right */
+.chai-reveal--fade-in-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(30px);
+}
+
+.chai-reveal--fade-in-right[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* ============================================
+ SLIDE ANIMATIONS
+ ============================================ */
+
+/* Slide In Up */
+.chai-reveal--slide-in-up[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(100%);
+}
+
+.chai-reveal--slide-in-up[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Slide In Down */
+.chai-reveal--slide-in-down[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(-100%);
+}
+
+.chai-reveal--slide-in-down[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Slide In Left */
+.chai-reveal--slide-in-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(-90%);
+}
+
+.chai-reveal--slide-in-left[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* Slide In Right */
+.chai-reveal--slide-in-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(90%);
+}
+
+.chai-reveal--slide-in-right[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* ============================================
+ ZOOM ANIMATIONS
+ ============================================ */
+
+/* Zoom In */
+.chai-reveal--zoom-in[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.5);
+}
+
+.chai-reveal--zoom-in[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1);
+}
+
+/* Zoom In Up */
+.chai-reveal--zoom-in-up[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.5) translateY(30px);
+}
+
+.chai-reveal--zoom-in-up[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+}
+
+/* Zoom In Down */
+.chai-reveal--zoom-in-down[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.5) translateY(-30px);
+}
+
+.chai-reveal--zoom-in-down[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+}
+
+/* Zoom In Left */
+.chai-reveal--zoom-in-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.5) translateX(-30px);
+}
+
+.chai-reveal--zoom-in-left[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1) translateX(0);
+}
+
+/* Zoom In Right */
+.chai-reveal--zoom-in-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.5) translateX(30px);
+}
+
+.chai-reveal--zoom-in-right[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1) translateX(0);
+}
+
+/* ============================================
+ FLIP ANIMATIONS
+ ============================================ */
+
+/* Flip In X */
+.chai-reveal--flip-in-x[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: perspective(400px) rotateX(90deg);
+}
+
+.chai-reveal--flip-in-x[data-visible="true"] {
+ opacity: 1;
+ transform: perspective(400px) rotateX(0);
+}
+
+/* Flip In Y */
+.chai-reveal--flip-in-y[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: perspective(400px) rotateY(90deg);
+}
+
+.chai-reveal--flip-in-y[data-visible="true"] {
+ opacity: 1;
+ transform: perspective(400px) rotateY(0);
+}
+
+/* ============================================
+ ROTATE ANIMATIONS
+ ============================================ */
+
+/* Rotate In */
+.chai-reveal--rotate-in[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: rotate(-180deg) scale(0.5);
+}
+
+.chai-reveal--rotate-in[data-visible="true"] {
+ opacity: 1;
+ transform: rotate(0) scale(1);
+}
+
+/* Rotate In Up Left */
+.chai-reveal--rotate-in-up-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: rotate(-45deg);
+ transform-origin: left bottom;
+}
+
+.chai-reveal--rotate-in-up-left[data-visible="true"] {
+ opacity: 1;
+ transform: rotate(0);
+ transform-origin: left bottom;
+}
+
+/* Rotate In Up Right */
+.chai-reveal--rotate-in-up-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: rotate(45deg);
+ transform-origin: right bottom;
+}
+
+.chai-reveal--rotate-in-up-right[data-visible="true"] {
+ opacity: 1;
+ transform: rotate(0);
+ transform-origin: right bottom;
+}
+
+/* Rotate In Down Left */
+.chai-reveal--rotate-in-down-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: rotate(45deg);
+ transform-origin: left top;
+}
+
+.chai-reveal--rotate-in-down-left[data-visible="true"] {
+ opacity: 1;
+ transform: rotate(0);
+ transform-origin: left top;
+}
+
+/* Rotate In Down Right */
+.chai-reveal--rotate-in-down-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: rotate(-45deg);
+ transform-origin: right top;
+}
+
+.chai-reveal--rotate-in-down-right[data-visible="true"] {
+ opacity: 1;
+ transform: rotate(0);
+ transform-origin: right top;
+}
+
+/* ============================================
+ BOUNCE ANIMATIONS
+ ============================================ */
+
+/* Bounce In */
+.chai-reveal--bounce-in[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: scale(0.3);
+}
+
+.chai-reveal--bounce-in[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1);
+ animation: chai-bounce-in var(--chai-animation-duration, 0.6s)
+ var(--chai-animation-easing, cubic-bezier(0.68, -0.55, 0.265, 1.55));
+}
+
+@keyframes chai-bounce-in {
+ 0% {
+ opacity: 0;
+ transform: scale(0.3);
+ }
+ 50% {
+ transform: scale(1.05);
+ }
+ 70% {
+ transform: scale(0.9);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Bounce In Up */
+.chai-reveal--bounce-in-up[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(50px);
+}
+
+.chai-reveal--bounce-in-up[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+ animation: chai-bounce-in-up var(--chai-animation-duration, 0.6s)
+ var(--chai-animation-easing, cubic-bezier(0.68, -0.55, 0.265, 1.55));
+}
+
+@keyframes chai-bounce-in-up {
+ 0% {
+ opacity: 0;
+ transform: translateY(50px);
+ }
+ 60% {
+ transform: translateY(-10px);
+ }
+ 80% {
+ transform: translateY(5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Bounce In Down */
+.chai-reveal--bounce-in-down[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateY(-50px);
+}
+
+.chai-reveal--bounce-in-down[data-visible="true"] {
+ opacity: 1;
+ transform: translateY(0);
+ animation: chai-bounce-in-down var(--chai-animation-duration, 0.6s)
+ var(--chai-animation-easing, cubic-bezier(0.68, -0.55, 0.265, 1.55));
+}
+
+@keyframes chai-bounce-in-down {
+ 0% {
+ opacity: 0;
+ transform: translateY(-50px);
+ }
+ 60% {
+ transform: translateY(10px);
+ }
+ 80% {
+ transform: translateY(-5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Bounce In Left */
+.chai-reveal--bounce-in-left[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(-50px);
+}
+
+.chai-reveal--bounce-in-left[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+ animation: chai-bounce-in-left var(--chai-animation-duration, 0.6s)
+ var(--chai-animation-easing, cubic-bezier(0.68, -0.55, 0.265, 1.55));
+}
+
+@keyframes chai-bounce-in-left {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50px);
+ }
+ 60% {
+ transform: translateX(10px);
+ }
+ 80% {
+ transform: translateX(-5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+/* Bounce In Right */
+.chai-reveal--bounce-in-right[data-animate="true"]:not([data-visible="true"]) {
+ opacity: 0;
+ transform: translateX(50px);
+}
+
+.chai-reveal--bounce-in-right[data-visible="true"] {
+ opacity: 1;
+ transform: translateX(0);
+ animation: chai-bounce-in-right var(--chai-animation-duration, 0.6s)
+ var(--chai-animation-easing, cubic-bezier(0.68, -0.55, 0.265, 1.55));
+}
+
+@keyframes chai-bounce-in-right {
+ 0% {
+ opacity: 0;
+ transform: translateX(50px);
+ }
+ 60% {
+ transform: translateX(-10px);
+ }
+ 80% {
+ transform: translateX(5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
diff --git a/src/render/animation/animation-utils.ts b/src/render/animation/animation-utils.ts
new file mode 100644
index 00000000..526b0bfd
--- /dev/null
+++ b/src/render/animation/animation-utils.ts
@@ -0,0 +1,141 @@
+import { isEmpty, join, split, toNumber } from "lodash-es";
+
+export type TAnimation = {
+ type: string;
+ direction: string;
+ duration: number;
+ delay: number;
+ easing: string;
+ triggerOnce: boolean;
+};
+
+export const ANIMATION_TYPES = [
+ { value: "fade", label: "Fade" },
+ { value: "slide", label: "Slide" },
+ { value: "zoom", label: "Zoom" },
+ { value: "flip", label: "Flip" },
+ { value: "rotate", label: "Rotate" },
+ { value: "bounce", label: "Bounce" },
+];
+
+export const EASING_OPTIONS = [
+ { value: "ease-linear", label: "Linear" },
+ { value: "ease-in", label: "Ease In" },
+ { value: "ease-out", label: "Ease Out" },
+ { value: "ease-in-out", label: "Ease In Out" },
+ { value: "ease-in-back", label: "Ease In Back" },
+ { value: "ease-out-back", label: "Ease Out Back" },
+ { value: "ease-in-out-back", label: "Ease In Out Back" },
+];
+
+export const getDirectionOptions = (type: string) => {
+ switch (type) {
+ case "slide":
+ return [
+ { value: "up", label: "Up" },
+ { value: "down", label: "Down" },
+ { value: "left", label: "Left" },
+ { value: "right", label: "Right" },
+ ];
+ case "fade":
+ case "zoom":
+ case "bounce":
+ return [
+ { value: "none", label: "Default" },
+ { value: "up", label: "Up" },
+ { value: "down", label: "Down" },
+ { value: "left", label: "Left" },
+ { value: "right", label: "Right" },
+ ];
+ case "flip":
+ return [
+ { value: "x", label: "Horizontal (X)" },
+ { value: "y", label: "Vertical (Y)" },
+ ];
+ case "rotate":
+ return [
+ { value: "none", label: "Default" },
+ { value: "up-left", label: "Up Left" },
+ { value: "up-right", label: "Up Right" },
+ { value: "down-left", label: "Down Left" },
+ { value: "down-right", label: "Down Right" },
+ ];
+ default:
+ return [{ value: "none", label: "Default" }];
+ }
+};
+
+export const DEFAULT_ANIMATION = {
+ delay: 10,
+ duration: 500,
+ triggerOnce: true,
+ type: "fade",
+ direction: "up",
+ easing: "ease-linear",
+};
+
+export const convertToAnimationString = (animation: TAnimation): string => {
+ if (isEmpty(animation)) return "";
+ const { type, direction, easing, duration, delay, triggerOnce } = animation;
+ return join([type, direction, easing, duration, delay, triggerOnce ? "1" : "0"], "|");
+};
+
+export const convertToAnimationObject = (animationString: string): TAnimation => {
+ const [type, direction, easing, duration, delay, triggerOnce] = split(animationString, "|");
+ return {
+ type,
+ easing,
+ direction,
+ delay: toNumber(delay),
+ duration: toNumber(duration),
+ triggerOnce: triggerOnce === "1",
+ };
+};
+
+export const getAnimationClassName = (type: string, direction: string): string => {
+ if (!type) return "";
+
+ // Map type to CSS class base name (CSS uses "fade-in", "slide-in", "zoom-in", etc.)
+ const typeMap: Record = {
+ fade: "fade-in",
+ slide: "slide-in",
+ zoom: "zoom-in",
+ flip: "flip-in",
+ rotate: "rotate-in",
+ bounce: "bounce-in",
+ };
+
+ const baseType = typeMap[type] || type;
+
+ if (!direction || direction === "none") {
+ return `chai-reveal--${baseType}`;
+ }
+ return `chai-reveal--${baseType}-${direction}`;
+};
+
+export const applyAnimationStyles = (element: Element, animation: TAnimation): void => {
+ const { type, direction, easing, duration, delay } = animation;
+
+ element.classList.add("chai-reveal");
+
+ const animationClass = getAnimationClassName(type, direction);
+ if (animationClass) {
+ element.classList.add(animationClass);
+ }
+
+ if (easing) {
+ element.classList.add(`chai-reveal--${easing}`);
+ }
+
+ const el = element as HTMLElement;
+ if (duration) {
+ // Duration is stored in ms, convert to seconds for CSS
+ el.style.setProperty("--chai-animation-duration", `${duration / 1000}s`);
+ }
+ if (delay) {
+ // Delay is stored in ms, convert to seconds for CSS
+ el.style.setProperty("--chai-animation-delay", `${delay / 1000}s`);
+ }
+
+ el.setAttribute("data-animate", "true");
+};
diff --git a/src/render/animation/use-chai-animation.ts b/src/render/animation/use-chai-animation.ts
new file mode 100644
index 00000000..84e42428
--- /dev/null
+++ b/src/render/animation/use-chai-animation.ts
@@ -0,0 +1,79 @@
+import { useEffect } from "react";
+import { applyAnimationStyles, convertToAnimationObject } from "./animation-utils";
+
+const ATTR = "data-animation";
+
+export const useChaiAnimation = () => {
+ useEffect(() => {
+ const observers: IntersectionObserver[] = [];
+ const initializedElements = new WeakSet();
+
+ const initializeAnimations = () => {
+ const nodes = document.querySelectorAll(`[${ATTR}]`);
+
+ nodes.forEach((node) => {
+ if (initializedElements.has(node)) return;
+
+ const animationString = node.getAttribute(ATTR);
+ if (!animationString) return;
+
+ initializedElements.add(node);
+ const animation = convertToAnimationObject(animationString);
+ applyAnimationStyles(node, animation);
+
+ // Small delay to ensure hidden state is painted before observing
+ setTimeout(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ entry.target.setAttribute("data-visible", "true");
+ if (animation.triggerOnce) {
+ observer.unobserve(entry.target);
+ }
+ } else if (!animation.triggerOnce) {
+ entry.target.setAttribute("data-visible", "false");
+ }
+ });
+ },
+ {
+ threshold: 0.1,
+ rootMargin: "0px 0px -50px 0px",
+ },
+ );
+
+ observer.observe(node as Element);
+ observers.push(observer);
+ }, 50);
+ });
+ };
+
+ // Small delay to ensure DOM is ready after React render
+ const timeoutId = requestAnimationFrame(() => {
+ initializeAnimations();
+ });
+
+ // Watch for new elements with data-animation attribute
+ const mutationObserver = new MutationObserver((mutations) => {
+ let hasNewAnimationElements = false;
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (node instanceof Element) {
+ if (node.hasAttribute(ATTR) || node.querySelector(`[${ATTR}]`)) {
+ hasNewAnimationElements = true;
+ }
+ }
+ });
+ });
+ if (hasNewAnimationElements) initializeAnimations();
+ });
+
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
+
+ return () => {
+ cancelAnimationFrame(timeoutId);
+ observers.forEach((observer) => observer.disconnect());
+ mutationObserver.disconnect();
+ };
+ }, []);
+};
diff --git a/src/render/render-chai-blocks.tsx b/src/render/render-chai-blocks.tsx
index 5396bb17..ca88223c 100644
--- a/src/render/render-chai-blocks.tsx
+++ b/src/render/render-chai-blocks.tsx
@@ -1,3 +1,4 @@
+import AnimationContainer from "@/render/animation/animation-container";
import { ChaiBlock } from "@/types/chai-block";
import { ChaiPageProps } from "@chaibuilder/runtime";
import { cloneDeep, find, forEach, get, isEmpty, isObject, isString, keys } from "lodash-es";
@@ -65,5 +66,9 @@ export function RenderChaiBlocks(props: RenderChaiBlocksProps) {
const lang = props.lang ?? "en";
const fallbackLang = props.fallbackLang ?? lang;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/src/types/chaibuilder-editor-props.ts b/src/types/chaibuilder-editor-props.ts
index 2216666c..9b9bbb4e 100644
--- a/src/types/chaibuilder-editor-props.ts
+++ b/src/types/chaibuilder-editor-props.ts
@@ -302,6 +302,7 @@ export interface ChaiBuilderEditorProps {
dragAndDrop?: boolean;
validateStructure?: boolean;
designTokens?: boolean;
+ animation?: boolean;
};
structureRules?: StructureRule[];