A wrapper that bounces away from the cursor on hover and springs back into place.
npx atelier-ui add edge-bouncenpm install motionimport { useAnimate, useMotionValue, useVelocity } from "motion/react"
import type React from "react"
import { type ComponentRef, type ReactNode, useEffect, useRef } from "react"
const MAX_BOUNCE_SPEED = 1500
export type EdgeBounceProps = {
children: ReactNode
className?: string
pause?: number
outDuration?: number
inDuration?: number
bounce?: number
distance?: number
rotation?: number
}
export function EdgeBounce({
children,
className,
pause = 0,
outDuration = 0.35,
inDuration = 1,
bounce = 0.3,
distance = 35,
rotation = 25,
}: EdgeBounceProps) {
const [scope, animate] = useAnimate<ComponentRef<"div">>()
const isBouncing = useRef(false)
const touchInside = useRef(false)
const pointerX = useMotionValue(0)
const pointerY = useMotionValue(0)
const velocityX = useVelocity(pointerX)
const velocityY = useVelocity(pointerY)
const bounceFrom = async (fromX: number, fromY: number) => {
const element = scope.current
if (!element || isBouncing.current) return
const velX = velocityX.get()
const velY = velocityY.get()
const speed = Math.hypot(velX, velY)
const speedRatio = Math.min(speed / MAX_BOUNCE_SPEED, 1)
const rect = element.getBoundingClientRect()
const deltaX = rect.left + rect.width / 2 - fromX
const deltaY = rect.top + rect.height / 2 - fromY
const distanceFromCenter = Math.hypot(deltaX, deltaY)
if (distanceFromCenter === 0 || speed === 0) return
const tilt = (deltaY * velX - deltaX * velY) / (distanceFromCenter * speed)
isBouncing.current = true
await animate(
element,
{
x: (deltaX / distanceFromCenter) * distance * speedRatio,
y: (deltaY / distanceFromCenter) * distance * speedRatio,
rotate: tilt * rotation * speedRatio,
},
{ duration: outDuration, ease: "circOut" },
)
isBouncing.current = false
animate(
element,
{ x: 0, y: 0, rotate: 0 },
{ type: "spring", duration: inDuration, bounce, delay: pause },
)
}
useEffect(() => {
const isOver = (event: PointerEvent) => {
const rect = scope.current?.getBoundingClientRect()
if (!rect) return false
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
)
}
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === "touch") touchInside.current = isOver(event)
}
const handlePointerMove = (event: PointerEvent) => {
pointerX.set(event.clientX)
pointerY.set(event.clientY)
if (event.pointerType !== "touch") return
const inside = isOver(event)
if (inside && !touchInside.current) bounceFrom(event.clientX, event.clientY)
touchInside.current = inside
}
window.addEventListener("pointerdown", handlePointerDown)
window.addEventListener("pointermove", handlePointerMove)
return () => {
window.removeEventListener("pointerdown", handlePointerDown)
window.removeEventListener("pointermove", handlePointerMove)
}
})
const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "touch") bounceFrom(event.clientX, event.clientY)
}
return (
<div
ref={scope}
className={`table will-change-transform ${className ?? ""}`}
onPointerEnter={handlePointerEnter}
>
{children}
</div>
)
}
## Integrate the <EdgeBounce /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: EdgeBounce
### Description: A wrapper that bounces away from the cursor on hover and springs back into place.
### Dependencies: motion
---
### Usage Example
Wrap the content that should bounce away from the cursor.
```tsx
<EdgeBounce>
<img src="https://picsum.photos/seed/atelier-1/1200/800" alt="Photo" />
</EdgeBounce>
```
---
### Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | Content to render inside the wrapper. Required. |
| `className` | `string` | — | Class names applied to the wrapper. |
| `pause` | `number` | `0` | Delay in seconds before springing back. |
| `outDuration` | `number` | `0.35` | Duration in seconds for the wrapper moving away. |
| `inDuration` | `number` | `1` | Duration in seconds for the spring-back animation. |
| `bounce` | `number` | `0.3` | Bounciness of the spring-back. `0` = no overshoot, `1` = maximum oscillation. |
| `distance` | `number` | `35` | Maximum pixels the wrapper moves from its origin, reached at full pointer speed. |
| `rotation` | `number` | `25` | Maximum rotation in degrees, following the pointer's travel direction. `0` disables it. |
---
### Full Component Source
#### src/components/edge-bounce/edge-bounce.tsx
```tsx
import { useAnimate, useMotionValue, useVelocity } from "motion/react"
import type React from "react"
import { type ComponentRef, type ReactNode, useEffect, useRef } from "react"
const MAX_BOUNCE_SPEED = 1500
export type EdgeBounceProps = {
children: ReactNode
className?: string
pause?: number
outDuration?: number
inDuration?: number
bounce?: number
distance?: number
rotation?: number
}
export function EdgeBounce({
children,
className,
pause = 0,
outDuration = 0.35,
inDuration = 1,
bounce = 0.3,
distance = 35,
rotation = 25,
}: EdgeBounceProps) {
const [scope, animate] = useAnimate<ComponentRef<"div">>()
const isBouncing = useRef(false)
const touchInside = useRef(false)
const pointerX = useMotionValue(0)
const pointerY = useMotionValue(0)
const velocityX = useVelocity(pointerX)
const velocityY = useVelocity(pointerY)
const bounceFrom = async (fromX: number, fromY: number) => {
const element = scope.current
if (!element || isBouncing.current) return
const velX = velocityX.get()
const velY = velocityY.get()
const speed = Math.hypot(velX, velY)
const speedRatio = Math.min(speed / MAX_BOUNCE_SPEED, 1)
const rect = element.getBoundingClientRect()
const deltaX = rect.left + rect.width / 2 - fromX
const deltaY = rect.top + rect.height / 2 - fromY
const distanceFromCenter = Math.hypot(deltaX, deltaY)
if (distanceFromCenter === 0 || speed === 0) return
const tilt = (deltaY * velX - deltaX * velY) / (distanceFromCenter * speed)
isBouncing.current = true
await animate(
element,
{
x: (deltaX / distanceFromCenter) * distance * speedRatio,
y: (deltaY / distanceFromCenter) * distance * speedRatio,
rotate: tilt * rotation * speedRatio,
},
{ duration: outDuration, ease: "circOut" },
)
isBouncing.current = false
animate(
element,
{ x: 0, y: 0, rotate: 0 },
{ type: "spring", duration: inDuration, bounce, delay: pause },
)
}
useEffect(() => {
const isOver = (event: PointerEvent) => {
const rect = scope.current?.getBoundingClientRect()
if (!rect) return false
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
)
}
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === "touch") touchInside.current = isOver(event)
}
const handlePointerMove = (event: PointerEvent) => {
pointerX.set(event.clientX)
pointerY.set(event.clientY)
if (event.pointerType !== "touch") return
const inside = isOver(event)
if (inside && !touchInside.current) bounceFrom(event.clientX, event.clientY)
touchInside.current = inside
}
window.addEventListener("pointerdown", handlePointerDown)
window.addEventListener("pointermove", handlePointerMove)
return () => {
window.removeEventListener("pointerdown", handlePointerDown)
window.removeEventListener("pointermove", handlePointerMove)
}
})
const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "touch") bounceFrom(event.clientX, event.clientY)
}
return (
<div
ref={scope}
className={`table will-change-transform ${className ?? ""}`}
onPointerEnter={handlePointerEnter}
>
{children}
</div>
)
}
```
---
### Integration Instructions
1. If you can execute shell commands, run `npx atelier-ui add edge-bounce` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: motion.
3. Copy each file from the component source above to the exact path shown.
4. Render `<EdgeBounce />` where it belongs in the app, using the usage example as a starting point and the props table to adjust it.
Full documentation: https://atelier-ui.com/en/docs/components/cursor/edge-bounceWrap the content that should bounce away from the cursor.
<EdgeBounce>
<img src="https://picsum.photos/seed/atelier-1/1200/800" alt="Photo" />
</EdgeBounce>| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content to render inside the wrapper. Required. |
className | string | — | Class names applied to the wrapper. |
pause | number | 0 | Delay in seconds before springing back. |
outDuration | number | 0.35 | Duration in seconds for the wrapper moving away. |
inDuration | number | 1 | Duration in seconds for the spring-back animation. |
bounce | number | 0.3 | Bounciness of the spring-back. 0 = no overshoot, 1 = maximum oscillation. |
distance | number | 35 | Maximum pixels the wrapper moves from its origin, reached at full pointer speed. |
rotation | number | 25 | Maximum rotation in degrees, following the pointer's travel direction. 0 disables it. |