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 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
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,
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 },
})
}
| 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. |