Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (28)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Text Bounce

Text Bounce

Letters bounce away from the cursor on hover and spring back into place.

Motion
Tailwind CSS
https://atelier-ui.com/text-bounce

Settings

pause
0.00
out-duration
0.35
in-duration
0.80
bounce
0.50
distance
35
rotation
25
See the documentation below for more options.

Install

npx atelier-ui add text-bounce
npm install motion
text-bounce.tsx
import { 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>
    )
}
use-render.ts
// 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
        }
    }
}
text-split.tsx
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 },
    })
}

API

NameTypeDefaultDescription
childrenstring—Text content to render. Required.
pausenumber0Delay in seconds before springing back.
outDurationnumber0.35Duration in seconds for the letter drifting away.
inDurationnumber0.8Duration in seconds for the spring-back animation.
bouncenumber0.5Bounciness of the spring-back. 0 = no overshoot, 1 = maximum oscillation.
distancenumber35How many pixels the letter drifts from its origin.
rotationnumber25Max rotation in degrees applied along the horizontal drift axis. 0 disables it.
renderReactElement | function<span/>Override the wrapper element.
  • Install
  • API
Star on githubBuy me a coffeellms.txt