Letters bounce away from the cursor on hover and spring back into place.
npx atelier-ui add text-bouncenpm install motionimport { useAnimate } from "motion/react"
import type React from "react"
import type { RenderProp } from "../../hooks/use-render"
import { TextSplit } from "../text-split/text-split"
export type TextBounceProps = {
children: string
pause?: number
outDuration?: number
inDuration?: number
bounce?: number
distance?: number
rotation?: number
render?: RenderProp
}
type LetterProps = { char: string } & Required<
Pick<
TextBounceProps,
"pause" | "outDuration" | "inDuration" | "bounce" | "distance" | "rotation"
>
>
function Letter({ char, pause, outDuration, inDuration, bounce, distance, rotation }: LetterProps) {
const [scope, animate] = useAnimate<HTMLSpanElement>()
const handlePointerEnter = async (event: React.PointerEvent<HTMLSpanElement>) => {
const el = scope.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={scope}
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,
render,
}: TextBounceProps) {
return (
<TextSplit
render={render}
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>
)
}
// biome-ignore-all lint/suspicious/noExplicitAny: prop merging is inherently dynamic
/**
* Inspired by Base UI's `useRender` + `mergeProps`, intentionally simplified for this
* library's scope at the moment.
*
* Chosen over polymorphic prop: cleaner TypeScript, integrates better with other
* component (Next/Image, design systems, third-party UI libraries)
*
* @see https://base-ui.com/react/utils/use-render
* @see https://base-ui.com/react/utils/merge-props
*/
import { cloneElement, isValidElement, type ReactElement, type Ref } from "react"
type AnyProps = Record<string, any>
type RenderFunction<S> = (props: AnyProps, state: S) => ReactElement
export type RenderProp<S = void> = ReactElement | RenderFunction<S>
type UseRenderOptions<S> = {
render: RenderProp<S> | undefined
props: AnyProps
state?: S
defaultElement: ReactElement
}
export function useRender<S = void>(options: UseRenderOptions<S>): ReactElement<AnyProps> {
const { render, props, state, defaultElement } = options
const target = render ?? defaultElement
// Function form: consumer wires props themselves, no merging needed.
if (typeof target === "function") {
return target(props, state as S) as ReactElement<AnyProps>
}
// Element form: clone and merge our internal props with whatever the consumer set on the element.
const targetProps = (isValidElement(target) ? target.props : {}) as AnyProps
return cloneElement(target, mergeProps(props, targetProps)) as ReactElement<AnyProps>
}
function mergeProps(internal: AnyProps, external: AnyProps): AnyProps {
const merged: AnyProps = { ...internal }
for (const key in external) {
const internalValue = internal[key]
const externalValue = external[key]
if (key === "className" && typeof externalValue === "string") {
merged[key] = [internalValue, externalValue].filter(Boolean).join(" ")
} else if (key === "style" && externalValue && typeof externalValue === "object") {
merged[key] = { ...internalValue, ...externalValue }
} else if (key === "ref") {
merged[key] = composeRefs(internalValue, externalValue)
} else if (
key.startsWith("on") &&
typeof internalValue === "function" &&
typeof externalValue === "function"
) {
// External handler runs first so consumers can stopPropagation before our logic fires.
merged[key] = chainFunctions(externalValue, internalValue)
} else {
merged[key] = externalValue
}
}
return merged
}
function chainFunctions(...fns: Array<(...args: any[]) => void>) {
return (...args: any[]) => {
for (const fn of fns) fn(...args)
}
}
function composeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node)
else if (ref != null) (ref as { current: T | null }).current = node
}
}
}
import { Fragment, type ReactNode } from "react"
import { type RenderProp, useRender } from "../../hooks/use-render"
type SplitBy = "letters" | "words"
export type TextSplitProps = {
children: string
splitBy?: SplitBy
showMask?: boolean
renderItems?: (char: string, index: number) => ReactNode
render?: RenderProp
}
function splitText(text: string, splitBy: SplitBy) {
if (splitBy === "letters") return text.split("")
if (splitBy === "words") return text.split(" ")
return []
}
function Mask({ children, showMask }: { children: ReactNode; showMask: boolean }) {
if (!showMask) return children
return <span className="overflow-clip">{children}</span>
}
export function TextSplit({
children,
splitBy = "letters",
showMask = true,
renderItems,
render,
}: TextSplitProps) {
const parts = splitText(children, splitBy)
const content = parts.map((part, index) => {
const spacer = index < parts.length - 1 && " "
const elements = splitText(part, splitBy)
return (
<Mask showMask={showMask} key={index}>
{elements.map((char, i) => (
<Fragment key={i}>{renderItems ? renderItems(char, index) : char}</Fragment>
))}
{splitBy !== "letters" && spacer}
</Mask>
)
})
return useRender({
render,
defaultElement: <span />,
props: { children: content },
})
}
By default the text renders inside a <span>.
<TextBounce>Hover over me</TextBounce>Pass render to swap the wrapper for any DOM element. Your props are merged with the component's, so it stays the right semantic tag (heading, paragraph, link) while keeping the animation.
<TextBounce render={<h1 className="text-4xl font-bold" />}>Hover over me</TextBounce>| 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. |
render | ReactElement | function | <span/> | Override the wrapper element. |