diff --git a/src/packages/components/lazy-motion/.gitignore b/src/packages/components/lazy-motion/.gitignore
new file mode 100644
index 0000000..73ac1e6
--- /dev/null
+++ b/src/packages/components/lazy-motion/.gitignore
@@ -0,0 +1,42 @@
+
+# dependencies
+node_modules
+
+# build
+dist
+dist-ssr
+build
+out
+*.tsbuildinfo
+
+# testing & coverage
+coverage
+coverage-ts
+
+# env
+.env
+
+# Editor directories and files
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+*.local
+*.pem
+.idea
+.todo
+.DS_Store
+
+# vendors
+.turbo
+.vercel
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
diff --git a/src/packages/components/lazy-motion/CHANGELOG.md b/src/packages/components/lazy-motion/CHANGELOG.md
new file mode 100644
index 0000000..ce56d13
--- /dev/null
+++ b/src/packages/components/lazy-motion/CHANGELOG.md
@@ -0,0 +1,8 @@
+# @renderui/lazy-motion
+
+## 1.0.0
+
+### Patch changes
+
+- Added abstraction providers for framer-motion dom animationa and dom max features
+- Added provider compount component
diff --git a/src/packages/components/lazy-motion/bun.lockb b/src/packages/components/lazy-motion/bun.lockb
new file mode 100755
index 0000000..fb024d1
Binary files /dev/null and b/src/packages/components/lazy-motion/bun.lockb differ
diff --git a/src/packages/components/lazy-motion/package.json b/src/packages/components/lazy-motion/package.json
new file mode 100644
index 0000000..b3194c6
--- /dev/null
+++ b/src/packages/components/lazy-motion/package.json
@@ -0,0 +1,48 @@
+{
+ "author": {
+ "email": "lovro.zagar5@gmail.com",
+ "name": "Lovro Žagar"
+ },
+ "devDependencies": {
+ "bunchee": "^5.5.1",
+ "framer-motion": "^11.11.10",
+ "react": "19.0.0-rc-a960b92c-20240819",
+ "react-dom": "19.0.0-rc-a960b92c-20240819",
+ "types-react": "^19.0.0-rc.1",
+ "types-react-dom": "^19.0.0-rc.1",
+ "typescript": "^5.5.4"
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "default": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ }
+ },
+ "files": ["dist"],
+ "license": "MIT",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "name": "@renderui/lazy-motion",
+ "peerDependencies": {
+ "framer-motion": "^11.11.10",
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "private": false,
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/lovrozagar/renderui.git"
+ },
+ "scripts": {
+ "build": "bunchee -m"
+ },
+ "sideEffects": false,
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "version": "1.0.0"
+}
diff --git a/src/packages/components/lazy-motion/src/components/lazy-motion-dom-animation-provider.tsx b/src/packages/components/lazy-motion/src/components/lazy-motion-dom-animation-provider.tsx
new file mode 100644
index 0000000..80ecf82
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/components/lazy-motion-dom-animation-provider.tsx
@@ -0,0 +1,17 @@
+import { LazyMotion } from "framer-motion"
+import type { LazyMotionProviderProps } from "../types/lazy-motion-provider"
+
+const LazyMotionDomAnimationProvider = (props: LazyMotionProviderProps) => {
+ const { children, strict = true } = props
+
+ return (
+ import("../lib/dom-animation").then((res) => res.default)}
+ strict={strict}
+ >
+ {children}
+
+ )
+}
+
+export { LazyMotionDomAnimationProvider }
diff --git a/src/packages/components/lazy-motion/src/components/lazy-motion-dom-max-provider.tsx b/src/packages/components/lazy-motion/src/components/lazy-motion-dom-max-provider.tsx
new file mode 100644
index 0000000..3eb70f5
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/components/lazy-motion-dom-max-provider.tsx
@@ -0,0 +1,17 @@
+import { LazyMotion } from "framer-motion"
+import type { LazyMotionProviderProps } from "../types/lazy-motion-provider"
+
+const LazyMotionDomMaxProvider = (props: LazyMotionProviderProps) => {
+ const { children, strict = true } = props
+
+ return (
+ import("../lib/dom-max").then((res) => res.default)}
+ strict={strict}
+ >
+ {children}
+
+ )
+}
+
+export { LazyMotionDomMaxProvider }
diff --git a/src/packages/components/lazy-motion/src/components/lazy-motion-provider.tsx b/src/packages/components/lazy-motion/src/components/lazy-motion-provider.tsx
new file mode 100644
index 0000000..4c484a9
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/components/lazy-motion-provider.tsx
@@ -0,0 +1,15 @@
+import { LazyMotionDomAnimationProvider } from "./lazy-motion-dom-animation-provider"
+import { LazyMotionDomMaxProvider } from "./lazy-motion-dom-max-provider"
+
+type LazyMotionProviderCompoundComponent = JSX.Element & {
+ DomAnimation: typeof LazyMotionDomAnimationProvider
+ DomMax: typeof LazyMotionDomMaxProvider
+}
+
+/* biome-ignore lint/complexity/noUselessFragments: */
+const LazyMotionProvider = (<>>) as LazyMotionProviderCompoundComponent
+
+LazyMotionProvider.DomAnimation = LazyMotionDomAnimationProvider
+LazyMotionProvider.DomMax = LazyMotionDomMaxProvider
+
+export { LazyMotionProvider }
diff --git a/src/packages/components/lazy-motion/src/index.ts b/src/packages/components/lazy-motion/src/index.ts
new file mode 100644
index 0000000..d0a45e1
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/index.ts
@@ -0,0 +1,4 @@
+export { LazyMotionDomAnimationProvider } from "./components/lazy-motion-dom-animation-provider"
+export { LazyMotionDomMaxProvider } from "./components/lazy-motion-dom-max-provider"
+export { LazyMotionProvider } from "./components/lazy-motion-provider"
+export type { LazyMotionProviderProps } from "./types/lazy-motion-provider"
diff --git a/src/packages/components/lazy-motion/src/lib/dom-animation.ts b/src/packages/components/lazy-motion/src/lib/dom-animation.ts
new file mode 100644
index 0000000..c25206e
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/lib/dom-animation.ts
@@ -0,0 +1 @@
+export { domAnimation as default } from 'framer-motion'
diff --git a/src/packages/components/lazy-motion/src/lib/dom-max.ts b/src/packages/components/lazy-motion/src/lib/dom-max.ts
new file mode 100644
index 0000000..b61a58f
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/lib/dom-max.ts
@@ -0,0 +1 @@
+export { domMax as default } from "framer-motion"
diff --git a/src/packages/components/lazy-motion/src/types/lazy-motion-provider.ts b/src/packages/components/lazy-motion/src/types/lazy-motion-provider.ts
new file mode 100644
index 0000000..6c9d661
--- /dev/null
+++ b/src/packages/components/lazy-motion/src/types/lazy-motion-provider.ts
@@ -0,0 +1,6 @@
+import type { LazyMotion } from "framer-motion"
+import type { ComponentPropsWithRef } from "react"
+
+type LazyMotionProviderProps = Omit, "features">
+
+export type { LazyMotionProviderProps }
diff --git a/src/packages/components/lazy-motion/tsconfig.json b/src/packages/components/lazy-motion/tsconfig.json
new file mode 100644
index 0000000..d2db219
--- /dev/null
+++ b/src/packages/components/lazy-motion/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "include": ["src"]
+}
diff --git a/src/packages/components/ripple/.gitignore b/src/packages/components/ripple/.gitignore
new file mode 100644
index 0000000..73ac1e6
--- /dev/null
+++ b/src/packages/components/ripple/.gitignore
@@ -0,0 +1,42 @@
+
+# dependencies
+node_modules
+
+# build
+dist
+dist-ssr
+build
+out
+*.tsbuildinfo
+
+# testing & coverage
+coverage
+coverage-ts
+
+# env
+.env
+
+# Editor directories and files
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+*.local
+*.pem
+.idea
+.todo
+.DS_Store
+
+# vendors
+.turbo
+.vercel
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
diff --git a/src/packages/components/ripple/CHANGELOG.md b/src/packages/components/ripple/CHANGELOG.md
new file mode 100644
index 0000000..f1d6077
--- /dev/null
+++ b/src/packages/components/ripple/CHANGELOG.md
@@ -0,0 +1,7 @@
+# @renderui/ripple
+
+## 1.0.0
+
+### Patch changes
+
+- Added ripple component
diff --git a/src/packages/components/ripple/bun.lockb b/src/packages/components/ripple/bun.lockb
new file mode 100755
index 0000000..7ddc47e
Binary files /dev/null and b/src/packages/components/ripple/bun.lockb differ
diff --git a/src/packages/components/ripple/package.json b/src/packages/components/ripple/package.json
new file mode 100644
index 0000000..bc74cf0
--- /dev/null
+++ b/src/packages/components/ripple/package.json
@@ -0,0 +1,56 @@
+{
+ "author": {
+ "email": "lovro.zagar5@gmail.com",
+ "name": "Lovro Žagar"
+ },
+ "devDependencies": {
+ "@renderui/lazy-motion": ">=1.0.0",
+ "@renderui/sub-layer": ">=1.0.0",
+ "@renderui/utils": ">=0.2.6",
+ "@renderui/hooks": ">=1.0.0",
+ "bunchee": "^5.5.1",
+ "framer-motion": "^11.11.10",
+ "react": "19.0.0-rc-a960b92c-20240819",
+ "react-dom": "19.0.0-rc-a960b92c-20240819",
+ "types-react": "^19.0.0-rc.1",
+ "types-react-dom": "^19.0.0-rc.1",
+ "typescript": "^5.5.4"
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "default": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ }
+ },
+ "files": ["dist"],
+ "license": "MIT",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "name": "@renderui/ripple",
+ "peerDependencies": {
+ "@renderui/lazy-motion": ">=1.0.0",
+ "@renderui/sub-layer": ">=1.0.0",
+ "@renderui/utils": ">=0.2.6",
+ "@renderui/hooks": ">=1.0.0",
+ "framer-motion": "^11.11.10",
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "private": false,
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/lovrozagar/renderui.git"
+ },
+ "scripts": {
+ "build": "bunchee -m"
+ },
+ "sideEffects": false,
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "version": "1.0.0"
+}
diff --git a/src/packages/components/ripple/src/components/ripple.tsx b/src/packages/components/ripple/src/components/ripple.tsx
new file mode 100644
index 0000000..35ba765
--- /dev/null
+++ b/src/packages/components/ripple/src/components/ripple.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { LazyMotionDomAnimationProvider } from "@renderui/lazy-motion"
+import { SubLayer } from "@renderui/sub-layer"
+import { getOptionalObject } from "@renderui/utils"
+import { AnimatePresence, m } from "framer-motion"
+import { useRipple } from "../hooks/use-ripple"
+import type { RippleProps } from "../types/ripple"
+
+const Ripple = (props: RippleProps) => {
+ const { subLayerProps, ...otherProps } = getOptionalObject(props)
+
+ const { ripples, internalSubLayerRef, addRippleOnPress, getRippleRipplesProps } =
+ useRipple(otherProps)
+
+ return (
+
+ {ripples.map((ripple) => (
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export { Ripple }
diff --git a/src/packages/components/ripple/src/constants/constants.ts b/src/packages/components/ripple/src/constants/constants.ts
new file mode 100644
index 0000000..fc378a6
--- /dev/null
+++ b/src/packages/components/ripple/src/constants/constants.ts
@@ -0,0 +1,49 @@
+const DEFAULT_RIPPLE_CLASSNAME = "_ripple bg-current"
+
+const RIPPLE_ANIMATION_END_OPACITY = 0
+const RIPPLE_ANIMATION_START_SCALE = 0
+const RIPPLE_ANIMATION_END_DEFAULT_SCALE = 2
+
+const RIPPLE_DEFAULT_OPACITY = 0.25
+const RIPPLE_DEFAULT_DURATION_MULTIPLIER = 1
+const RIPPLE_DEFAULT_SCALE_MULTIPLIER = 1
+
+const RIPPLE_BASE_MULTIPLIER = 0.01
+const RIPPLE_MIDDLE_DURATION = 0.25
+const RIPPLE_SIZE_100 = 100
+const RIPPLE_OVER_100_DURATION = 0.75
+const RIPPLE_UNDER_100_DURATION = 0.675
+
+const RIPPLE_RIPPLE_BASE_STYLE = {
+ backgroundColor: "current",
+ position: "absolute",
+ borderRadius: "100%",
+ transformOrigin: "center",
+ pointerEvents: "none",
+ zIndex: "0",
+} as const
+
+const KEYBOARD_RIPPLE_DATASET_ATTRIBUTE = "data-keyboard-pressed"
+
+const KEYBOARD_RIPPLE_MUTATION_OBSERVER_OPTIONS = {
+ attributes: true,
+ attributeFilter: [KEYBOARD_RIPPLE_DATASET_ATTRIBUTE],
+}
+
+export {
+ DEFAULT_RIPPLE_CLASSNAME,
+ KEYBOARD_RIPPLE_MUTATION_OBSERVER_OPTIONS,
+ RIPPLE_ANIMATION_END_DEFAULT_SCALE,
+ RIPPLE_ANIMATION_END_OPACITY,
+ RIPPLE_ANIMATION_START_SCALE,
+ RIPPLE_BASE_MULTIPLIER,
+ RIPPLE_DEFAULT_DURATION_MULTIPLIER,
+ RIPPLE_DEFAULT_OPACITY,
+ RIPPLE_DEFAULT_SCALE_MULTIPLIER,
+ RIPPLE_MIDDLE_DURATION,
+ RIPPLE_OVER_100_DURATION,
+ RIPPLE_RIPPLE_BASE_STYLE,
+ RIPPLE_SIZE_100,
+ RIPPLE_UNDER_100_DURATION,
+ KEYBOARD_RIPPLE_DATASET_ATTRIBUTE,
+}
diff --git a/src/packages/components/ripple/src/hooks/use-keyboard-ripple.ts b/src/packages/components/ripple/src/hooks/use-keyboard-ripple.ts
new file mode 100644
index 0000000..29fddaa
--- /dev/null
+++ b/src/packages/components/ripple/src/hooks/use-keyboard-ripple.ts
@@ -0,0 +1,51 @@
+import { useMutationObserver } from "@renderui/hooks"
+import { type Dispatch, type RefObject, type SetStateAction, useCallback } from "react"
+import { KEYBOARD_RIPPLE_DATASET_ATTRIBUTE, KEYBOARD_RIPPLE_MUTATION_OBSERVER_OPTIONS } from "../constants/constants"
+import type { RippleRipple } from "../types/ripple-ripple"
+import { createRipple } from "../utils/create-ripple"
+
+function useKeyboardRipple(
+ ref: RefObject,
+ setRipples: Dispatch>,
+) {
+ const addRippleOnKeyboardPress = useCallback(
+ (height: number, width: number) => {
+ const newRipple = createRipple({ type: "keyboard", width, height })
+
+ setRipples((previousRipples) => [...previousRipples, newRipple])
+
+ return newRipple.key
+ },
+ [setRipples],
+ )
+
+ const mutationHandler = useCallback(
+ (mutations: MutationRecord[]) => {
+ const element = ref.current?.parentElement
+ const parentElement = element
+
+ if (!parentElement) return
+
+ mutations.forEach((mutation) => {
+ if (mutation.attributeName !== KEYBOARD_RIPPLE_DATASET_ATTRIBUTE) return
+
+ const parentDatasetState = parentElement.dataset.pressed
+
+ if (parentDatasetState === "true") return
+
+ addRippleOnKeyboardPress(element.clientHeight, element.clientWidth)
+ })
+ },
+ [ref, addRippleOnKeyboardPress],
+ )
+
+ const parentRef = ref.current?.parentElement ?? null
+
+ useMutationObserver({
+ node: parentRef,
+ options: KEYBOARD_RIPPLE_MUTATION_OBSERVER_OPTIONS,
+ callback: mutationHandler,
+ })
+}
+
+export { useKeyboardRipple }
diff --git a/src/packages/components/ripple/src/hooks/use-press-ripple.ts b/src/packages/components/ripple/src/hooks/use-press-ripple.ts
new file mode 100644
index 0000000..f34b1a4
--- /dev/null
+++ b/src/packages/components/ripple/src/hooks/use-press-ripple.ts
@@ -0,0 +1,45 @@
+import {
+ type Dispatch,
+ type MouseEvent,
+ type MouseEventHandler,
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useState,
+} from "react"
+import type { RippleRipple } from "../types/ripple-ripple"
+import { createRipple } from "../utils/create-ripple"
+
+function usePressRipple(
+ setRipples: Dispatch>,
+ isDisabled: boolean | undefined = undefined,
+) {
+ const [isRaised, setIsRaised] = useState(false)
+
+ useEffect(() => {
+ let timeoutId: NodeJS.Timeout | undefined = undefined
+
+ if (isDisabled) {
+ timeoutId = setTimeout(() => setIsRaised(true), 0)
+ } else {
+ setIsRaised(false)
+ }
+
+ return () => clearTimeout(timeoutId)
+ }, [isDisabled])
+
+ return useCallback(
+ (event: React.MouseEvent) => {
+ if (isRaised) return ""
+
+ const newRipple = createRipple({ type: "pointer", event: event })
+
+ setRipples((previousRipples) => [...previousRipples, newRipple])
+
+ return newRipple.key
+ },
+ [isRaised, setRipples],
+ ) as unknown as MouseEventHandler
+}
+
+export { usePressRipple }
diff --git a/src/packages/components/ripple/src/hooks/use-ripple.ts b/src/packages/components/ripple/src/hooks/use-ripple.ts
new file mode 100644
index 0000000..e1ad074
--- /dev/null
+++ b/src/packages/components/ripple/src/hooks/use-ripple.ts
@@ -0,0 +1,115 @@
+"use client"
+
+import { cn } from "@renderui/utils"
+import type { AnimationDefinition } from "framer-motion"
+import { type Key, type RefObject, useRef, useState } from "react"
+import {
+ DEFAULT_RIPPLE_CLASSNAME,
+ RIPPLE_ANIMATION_END_DEFAULT_SCALE,
+ RIPPLE_ANIMATION_END_OPACITY,
+ RIPPLE_ANIMATION_START_SCALE,
+ RIPPLE_DEFAULT_OPACITY,
+ RIPPLE_RIPPLE_BASE_STYLE,
+} from "../constants/constants"
+import type { RippleProps } from "../types/ripple"
+import type { RippleRipple } from "../types/ripple-ripple"
+import { getRippleDuration } from "../utils/get-ripple-duration"
+import { useKeyboardRipple } from "./use-keyboard-ripple"
+import { usePressRipple } from "./use-press-ripple"
+
+type UseRippleReturnType = {
+ ripples: RippleRipple[]
+ internalSubLayerRef: RefObject
+ addRippleOnPress: ReturnType
+ /* biome-ignore lint/suspicious/noExplicitAny: avoid external module reference error */
+ getRippleRipplesProps: (ripple: RippleRipple) => any
+}
+
+function useRipple(props: Omit): UseRippleReturnType {
+ const {
+ ref,
+ opacity: opacityProp,
+ duration: durationProp,
+ scale: scaleProp,
+ transition: transitionProp,
+ initial: initialProp,
+ animate: animateProp,
+ exit: exitProp,
+ style: styleProp,
+ className,
+ isDisabled,
+ onAnimationComplete: onAnimationCompleteProp,
+ ...restProps
+ } = props
+
+ const internalSubLayerRef = useRef(null)
+ const [ripples, setRipples] = useState([])
+
+ const addRippleOnPress = usePressRipple(setRipples, isDisabled)
+
+ useKeyboardRipple(internalSubLayerRef, setRipples)
+
+ const clearRipple = (key: Key) => {
+ setRipples((previousState) => previousState.filter((ripple) => ripple.key !== key))
+ }
+
+ const onAnimationComplete = (ripple: RippleRipple, definition: AnimationDefinition) => {
+ clearRipple(ripple.key)
+
+ if (onAnimationCompleteProp) {
+ onAnimationCompleteProp(definition)
+ }
+ }
+
+ const getRippleRipplesProps = (ripple: RippleRipple) => {
+ const duration = durationProp ?? getRippleDuration(ripple.size)
+
+ const scale = scaleProp ?? RIPPLE_ANIMATION_END_DEFAULT_SCALE
+
+ const initial = initialProp ?? {
+ transform: `scale(${RIPPLE_ANIMATION_START_SCALE})`,
+ opacity: opacityProp ?? RIPPLE_DEFAULT_OPACITY,
+ }
+
+ const animate = animateProp ?? {
+ transform: `scale(${scale})`,
+ opacity: RIPPLE_ANIMATION_END_OPACITY,
+ }
+
+ const exit = exitProp ?? { opacity: RIPPLE_ANIMATION_END_OPACITY }
+
+ const transition = { duration, ...transitionProp }
+
+ const style = {
+ ...RIPPLE_RIPPLE_BASE_STYLE,
+ top: ripple.y,
+ left: ripple.x,
+ width: `${ripple.size}px`,
+ height: `${ripple.size}px`,
+ ...styleProp,
+ }
+
+ return {
+ ref,
+ initial,
+ animate,
+ exit,
+ transition,
+ style,
+ "data-slot": "ripple",
+ className: cn(DEFAULT_RIPPLE_CLASSNAME, className),
+ onAnimationComplete: (definition: AnimationDefinition) =>
+ onAnimationComplete(ripple, definition),
+ ...restProps,
+ }
+ }
+
+ return {
+ ripples,
+ internalSubLayerRef,
+ addRippleOnPress,
+ getRippleRipplesProps,
+ }
+}
+
+export { useRipple }
diff --git a/src/packages/components/ripple/src/index.ts b/src/packages/components/ripple/src/index.ts
new file mode 100644
index 0000000..188b117
--- /dev/null
+++ b/src/packages/components/ripple/src/index.ts
@@ -0,0 +1,3 @@
+export { Ripple } from "./components/ripple";
+export type { RippleProps } from "./types/ripple";
+
diff --git a/src/packages/components/ripple/src/types/ripple-ripple.ts b/src/packages/components/ripple/src/types/ripple-ripple.ts
new file mode 100644
index 0000000..496491b
--- /dev/null
+++ b/src/packages/components/ripple/src/types/ripple-ripple.ts
@@ -0,0 +1,10 @@
+import type { Key } from "react"
+
+type RippleRipple = {
+ key: Key
+ x: number
+ y: number
+ size: number
+}
+
+export type { RippleRipple }
diff --git a/src/packages/components/ripple/src/types/ripple.ts b/src/packages/components/ripple/src/types/ripple.ts
new file mode 100644
index 0000000..eafd4be
--- /dev/null
+++ b/src/packages/components/ripple/src/types/ripple.ts
@@ -0,0 +1,18 @@
+import type { SubLayerProps } from "@renderui/sub-layer"
+import type { Simplify } from "@renderui/utils"
+import type { m } from "framer-motion"
+import type { ComponentPropsWithRef } from "react"
+
+type RipplePrimitiveProps = ComponentPropsWithRef
+
+type RippleCustomProps = {
+ isDisabled?: boolean
+ opacity?: number
+ duration?: number
+ scale?: number
+ subLayerProps?: SubLayerProps
+}
+
+type RippleProps = Simplify
+
+export type { RippleProps }
diff --git a/src/packages/components/ripple/src/utils/create-ripple.ts b/src/packages/components/ripple/src/utils/create-ripple.ts
new file mode 100644
index 0000000..b346648
--- /dev/null
+++ b/src/packages/components/ripple/src/utils/create-ripple.ts
@@ -0,0 +1,49 @@
+import type { MouseEvent } from "react"
+
+type CreateRipplePointerProps = {
+ type: "pointer"
+ event: React.MouseEvent
+}
+
+type CreateRippleKeyboardProps = {
+ type: "keyboard"
+ width: number
+ height: number
+}
+
+type CreateRippleProps = CreateRipplePointerProps | CreateRippleKeyboardProps
+
+function createRipple(props: CreateRippleProps) {
+ const { type } = props
+
+ const rippleKey = crypto.randomUUID()
+
+ const size =
+ type === "keyboard"
+ ? Math.max(props.width, props.height)
+ : Math.max(props.event.clientX, props.event.clientY)
+
+ const rect =
+ type === "keyboard" ? { x: 0, y: 0 } : props.event.currentTarget.getBoundingClientRect()
+
+ const coordinates =
+ type === "keyboard"
+ ? {
+ /* center keyboard ripple in middle of element */
+ x: 0,
+ y: -props.width / 2 + props.height / 2,
+ }
+ : {
+ /* origin point is the exact pointer click coordinate */
+ x: props.event.clientX - rect.x - size / 2,
+ y: props.event.clientY - rect.y - size / 2,
+ }
+
+ return {
+ key: rippleKey,
+ size,
+ ...coordinates,
+ }
+}
+
+export { createRipple }
diff --git a/src/packages/components/ripple/src/utils/get-ripple-duration.ts b/src/packages/components/ripple/src/utils/get-ripple-duration.ts
new file mode 100644
index 0000000..ad33e22
--- /dev/null
+++ b/src/packages/components/ripple/src/utils/get-ripple-duration.ts
@@ -0,0 +1,21 @@
+import {
+ RIPPLE_BASE_MULTIPLIER,
+ RIPPLE_MIDDLE_DURATION,
+ RIPPLE_OVER_100_DURATION,
+ RIPPLE_SIZE_100,
+ RIPPLE_UNDER_100_DURATION,
+} from "../constants/constants"
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max)
+}
+
+function getRippleDuration(rippleSize: number) {
+ return clamp(
+ RIPPLE_BASE_MULTIPLIER * rippleSize,
+ RIPPLE_MIDDLE_DURATION,
+ rippleSize > RIPPLE_SIZE_100 ? RIPPLE_OVER_100_DURATION : RIPPLE_UNDER_100_DURATION,
+ )
+}
+
+export { getRippleDuration }
diff --git a/src/packages/components/ripple/tsconfig.json b/src/packages/components/ripple/tsconfig.json
new file mode 100644
index 0000000..d2db219
--- /dev/null
+++ b/src/packages/components/ripple/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "include": ["src"]
+}
diff --git a/src/packages/hooks/shared/.gitignore b/src/packages/hooks/shared/.gitignore
index 0b62d33..73ac1e6 100644
--- a/src/packages/hooks/shared/.gitignore
+++ b/src/packages/hooks/shared/.gitignore
@@ -1,43 +1,42 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
-/node_modules
-/.pnp
-.pnp.js
-.yarn/install-state.gz
+node_modules
-# testing
-/coverage
+# build
+dist
+dist-ssr
+build
+out
+*.tsbuildinfo
-# next.js
-/.next/
-/out/
+# testing & coverage
+coverage
+coverage-ts
-# production
-/build
-/dist
+# env
+.env
-# misc
-.DS_Store
+# Editor directories and files
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+*.local
*.pem
+.idea
+.todo
+.DS_Store
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# local env files
-.env
-.env*.local
-
-# turbo
+# vendors
.turbo
-
-# vercel
.vercel
-# typescript
-*.tsbuildinfo
-
-# coverage-ts
-coverage-ts
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
diff --git a/src/packages/hooks/shared/CHANGELOG.md b/src/packages/hooks/shared/CHANGELOG.md
index 6137aac..44de6a9 100644
--- a/src/packages/hooks/shared/CHANGELOG.md
+++ b/src/packages/hooks/shared/CHANGELOG.md
@@ -1,7 +1,7 @@
-# @renderui/utils
+# @renderui/hooks
-## 0.2.4
+## 0.1.8
### Patch changes
-- Refactor and export shared utility functions
+- Added useFreshRef and useMutationObserverHooks, refactor from @renderui/core
diff --git a/src/packages/hooks/shared/bun.lockb b/src/packages/hooks/shared/bun.lockb
new file mode 100755
index 0000000..d0c8412
Binary files /dev/null and b/src/packages/hooks/shared/bun.lockb differ
diff --git a/src/packages/hooks/shared/package.json b/src/packages/hooks/shared/package.json
index 2c895e6..ab41f7b 100644
--- a/src/packages/hooks/shared/package.json
+++ b/src/packages/hooks/shared/package.json
@@ -1,45 +1,46 @@
{
- "author": {
- "email": "lovro.zagar5@gmail.com",
- "name": "Lovro Žagar"
- },
- "devDependencies": {
- "bunchee": "^5.5.1",
- "react": "19.0.0-rc-a960b92c-20240819",
- "react-dom": "19.0.0-rc-a960b92c-20240819",
- "types-react": "^19.0.0-rc.1",
- "types-react-dom": "^19.0.0-rc.1",
- "typescript": "^5.5.4"
- },
- "exports": {
- ".": {
- "import": {
- "default": "./dist/index.js",
- "types": "./dist/index.d.ts"
- }
- }
- },
- "files": ["dist"],
- "license": "MIT",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "name": "@renderui/hooks",
- "peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
- },
- "private": false,
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "https://github.com/lovrozagar/renderui"
- },
- "scripts": {
- "build": "bunchee -m"
- },
- "type": "module",
- "types": "./dist/index.d.ts",
- "version": "0.1.7"
+ "author": {
+ "email": "lovro.zagar5@gmail.com",
+ "name": "Lovro Žagar"
+ },
+ "devDependencies": {
+ "bunchee": "^5.5.1",
+ "react": "19.0.0-rc-a960b92c-20240819",
+ "react-dom": "19.0.0-rc-a960b92c-20240819",
+ "types-react": "^19.0.0-rc.1",
+ "types-react-dom": "^19.0.0-rc.1",
+ "typescript": "^5.5.4"
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "default": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ }
+ },
+ "files": ["dist"],
+ "license": "MIT",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "name": "@renderui/hooks",
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "private": false,
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/lovrozagar/renderui.git"
+ },
+ "scripts": {
+ "build": "bunchee -m"
+ },
+ "sideEffects": false,
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "version": "1.0.0"
}
diff --git a/src/packages/hooks/shared/src/index.ts b/src/packages/hooks/shared/src/index.ts
new file mode 100644
index 0000000..d57dbd0
--- /dev/null
+++ b/src/packages/hooks/shared/src/index.ts
@@ -0,0 +1,5 @@
+export { useFreshRef, type UseFreshRefReturn } from "./use-fresh-ref/use-fresh-ref"
+export {
+ useMutationObserver,
+ type UseMutationObserverProps,
+} from "./use-mutation-observer/use-mutation-observer"
diff --git a/src/packages/hooks/shared/src/use-fresh-ref/use-fresh-ref.ts b/src/packages/hooks/shared/src/use-fresh-ref/use-fresh-ref.ts
new file mode 100644
index 0000000..5566d78
--- /dev/null
+++ b/src/packages/hooks/shared/src/use-fresh-ref/use-fresh-ref.ts
@@ -0,0 +1,17 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+function useFreshRef(value: T) {
+ const ref = useRef(value)
+
+ useEffect(() => {
+ ref.current = value
+ }, [value])
+
+ return ref
+}
+
+type UseFreshRefReturn = ReturnType
+
+export { useFreshRef, type UseFreshRefReturn }
diff --git a/src/packages/hooks/shared/src/use-mutation-observer/use-mutation-observer.ts b/src/packages/hooks/shared/src/use-mutation-observer/use-mutation-observer.ts
new file mode 100644
index 0000000..5f9eff6
--- /dev/null
+++ b/src/packages/hooks/shared/src/use-mutation-observer/use-mutation-observer.ts
@@ -0,0 +1,43 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { useFreshRef } from "../use-fresh-ref/use-fresh-ref"
+
+type UseMutationObserverProps = {
+ node: T | null
+ callback: MutationCallback
+ options: MutationObserverInit
+ enabled?: boolean
+}
+
+function useMutationObserver(props: UseMutationObserverProps) {
+ const { node, callback, options, enabled } = props
+
+ const freshCallback = useFreshRef(callback)
+ const freshOptions = useFreshRef(options)
+ const observerRef = useRef(null)
+
+ /* biome-ignore lint/correctness/useExhaustiveDependencies: using fresh ref pattern, ref dep not needed */
+ useEffect(() => {
+ if (!node || !enabled) {
+ if (!observerRef.current) return
+
+ observerRef.current.disconnect()
+
+ return
+ }
+
+ if (observerRef.current) return
+
+ observerRef.current = new MutationObserver(freshCallback.current)
+ observerRef.current.observe(node, freshOptions.current)
+
+ return () => {
+ if (observerRef.current) {
+ observerRef.current.disconnect()
+ }
+ }
+ }, [node, enabled])
+}
+
+export { useMutationObserver, type UseMutationObserverProps }
diff --git a/src/packages/hooks/shared/tsconfig.json b/src/packages/hooks/shared/tsconfig.json
index 9bcd127..d2db219 100644
--- a/src/packages/hooks/shared/tsconfig.json
+++ b/src/packages/hooks/shared/tsconfig.json
@@ -1,4 +1,4 @@
{
- "extends": "../../../../tsconfig.json",
- "include": ["src", "index.ts"]
+ "extends": "../../../../tsconfig.json",
+ "include": ["src"]
}