Letters bounce away from the cursor on hover and spring back into place.
npx atelier-ui add text-bouncenpm install motionimport { animate } from "motion"
import type React from "react"
import { type ComponentRef, useRef } from "react"
import { TextSplit } from "../text-split/text-split"
export type TextBounceProps = {
children: string
pause?: number
outDuration?: number
inDuration?: number
bounce?: number
distance?: number
rotation?: number
}
type LetterProps = {
char: string
pause: number
outDuration: number
inDuration: number
bounce: number
distance: number
rotation: number
}
function Letter({ char, pause, outDuration, inDuration, bounce, distance, rotation }: LetterProps) {
const ref = useRef<ComponentRef<"span">>(null)
const handlePointerEnter = async (event: React.PointerEvent<HTMLSpanElement>) => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const dx = cx - event.clientX
const dy = cy - event.clientY
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const nx = dx / dist
const ny = dy / dist
await animate([
[
el,
{ x: nx * distance, y: ny * distance, rotate: nx * rotation },
{ duration: outDuration, ease: "circOut" },
],
[
el,
{ x: 0, y: 0, rotate: 0 },
{ type: "spring", duration: inDuration, bounce, delay: pause },
],
])
}
return (
<span
ref={ref}
className="inline-block will-change-transform whitespace-pre"
onPointerEnter={handlePointerEnter}
>
{char}
</span>
)
}
export function TextBounce({
children,
pause = 0,
outDuration = 0.35,
inDuration = 0.8,
bounce = 0.5,
distance = 35,
rotation = 25,
}: TextBounceProps) {
return (
<TextSplit
showMask={false}
splitBy="letters"
renderItems={(char, index) => {
return (
<Letter
key={index}
char={char}
pause={pause}
outDuration={outDuration}
inDuration={inDuration}
bounce={bounce}
distance={distance}
rotation={rotation}
/>
)
}}
>
{children}
</TextSplit>
)
}
import React from "react"
type SplitBy = "letters" | "words"
type SplitTextProps = {
children: string
splitBy?: SplitBy
showMask?: boolean
side?: "x" | "y"
renderItems?: (char: string, index: number) => React.ReactNode
}
type MaskProps = {
children: React.ReactNode
showMask: boolean
} & React.HTMLAttributes<HTMLSpanElement>
function splitText(text: string, splitBy: SplitBy) {
if (splitBy === "letters") return text.split("")
if (splitBy === "words") return text.split(" ")
return []
}
const Mask = ({ children, showMask, ...props }: MaskProps) => {
if (!showMask) return children
return (
<span className="overflow-clip" {...props}>
{children}
</span>
)
}
export function TextSplit({
children,
splitBy = "letters",
showMask = true,
renderItems,
...props
}: SplitTextProps & React.HTMLAttributes<HTMLSpanElement>) {
if (typeof children !== "string") throw new Error("SplitText only accepts string children")
const element = splitText(children, splitBy)
return (
<>
{element.map((part, index) => {
const spacer = index < element.length - 1 && " "
const elements = splitText(part, splitBy)
return (
<Mask {...props} showMask={showMask} key={index}>
{elements.map((char, i) => {
return (
<React.Fragment key={i}>
{renderItems ? renderItems(char, index) : char}
</React.Fragment>
)
})}
{splitBy !== "letters" && spacer}
</Mask>
)
})}
</>
)
}
| Name | Type | Default | Description |
|---|---|---|---|
children | string | — | Text content to render. Required. |
pause | number | 0 | Delay in seconds before springing back. |
outDuration | number | 0.35 | Duration in seconds for the letter drifting away. |
inDuration | number | 0.8 | Duration in seconds for the spring-back animation. |
bounce | number | 0.5 | Bounciness of the spring-back. 0 = no overshoot, 1 = maximum oscillation. |
distance | number | 35 | How many pixels the letter drifts from its origin. |
rotation | number | 25 | Max rotation in degrees applied along the horizontal drift axis. 0 disables it. |