Atelier UI®

DocsShader StudioGithub
Docs 1.0.0

Getting started

  • Installation
  • How to contribute

tools

  • Browse Catalog
  • Shader Studio
    new
  • Fluid Scene

Components (38)

  • Orbit Gallery
  • Sphere Gallery
  • Glowing Fog
    pro
  • Gradient Flow
    pro
  • Halftone Glow
    pro
  • Scattered Grid
    pro
  • Tag Cloud
    pro
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
    pro
  • Wavy Scroll
    pro
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Text Roll
    pro
  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • 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 { 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>
    )
}
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 },
    })
}
prompt.md
## Integrate the <TextBounce /> component from Atelier UI

You are helping integrate an open-source React component into an existing application.

### Component: TextBounce
### Description: Letters bounce away from the cursor on hover and spring back into place.
### Dependencies: motion

---

### Usage Example

By default the text renders inside a `<span>`.

```tsx title="Default"
<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.

```tsx title="Custom element"
<TextBounce render={<h1 className="text-4xl font-bold" />}>Hover over me</TextBounce>
```

---

### Props

| 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. |

---

### Full Component Source

#### src/components/text-bounce/text-bounce.tsx

```tsx
import { 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>
    )
}
```

#### src/hooks/use-render.ts

```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
        }
    }
}
```

#### src/components/text-split/text-split.tsx

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

---

### Integration Instructions

1. If you can execute shell commands, run `npx atelier-ui add text-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 `<TextBounce />` 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/text/text-bounce

Usage

By default the text renders inside a <span>.

Default
<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.

Custom element
<TextBounce render={<h1 className="text-4xl font-bold" />}>Hover over me</TextBounce>

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
  • Usage
  • API
Star on githubBuy me a coffeellms.txt