Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll

Foundation Blocks (07)

  • 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. Text Split

Text Split

A foundation block that splits a string into letters or words.

  • Install
  • With renderItems
  • API

A structural building block for composing your own text animations, like Text Bounce.

By default, each item is wrapped in an overflow-clip span (disable with showMask={false}). This keeps slide and reveal animations clipped to each letter's bounding box.


Install

Prompt
Add Atelier's Text Split to my app.

If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add https://atelier-ui.com/r/text-split.json
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.

This command will install all the dependencies this component uses.

npx shadcn@latest add https://atelier-ui.com/r/text-split.json

Install the dependencies first, then feel free to copy the files into your project as you see fit.

text-split.tsx
"use client"

import { Fragment, type ReactNode } from "react"
import { type RenderProp, useRender } from "../../hooks/use-render"

type SplitBy = "letters" | "words"

const NON_BREAKING_SPACE = " "

export type TextSplitProps = {
    children: string
    splitBy?: SplitBy
    showMask?: boolean
    renderItems?: (char: string, index: number) => ReactNode
    render?: RenderProp
}

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 words = children.split(" ")
    let cursor = 0

    const content = words.map((word, wordIndex) => {
        const isLast = wordIndex === words.length - 1

        if (splitBy === "words") {
            return (
                <Mask showMask={showMask} key={wordIndex}>
                    {renderItems ? renderItems(word, wordIndex) : word}
                    {!isLast && " "}
                </Mask>
            )
        }

        const letters = Array.from(word).map((char) => {
            const index = cursor++
            return (
                <Mask showMask={showMask} key={index}>
                    {renderItems ? renderItems(char, index) : char}
                </Mask>
            )
        })

        let spacer: ReactNode = null
        if (!isLast) {
            const index = cursor++
            spacer = (
                <Mask showMask={showMask} key={index}>
                    {renderItems ? renderItems(" ", index) : NON_BREAKING_SPACE}
                </Mask>
            )
        }

        return (
            <Fragment key={wordIndex}>
                <span className="inline-block">
                    {letters}
                    {spacer}
                </span>
                {!isLast && <wbr />}
            </Fragment>
        )
    })

    return useRender({
        render,
        defaultElement: <span />,
        props: { children: content },
    })
}
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
        }
    }
}

By default, the string is split into individual letters:

Split by letters
<TextSplit>Hello world</TextSplit>

Use splitBy="words" to split on spaces instead:

Split by words
<TextSplit splitBy="words">Hello world</TextSplit>

With renderItems

renderItems lets you render each piece yourself. Here, every letter lifts on hover:

Hover lift
<TextSplit
    renderItems={(char, i) => (
        <span key={i} className="inline-block transition-transform hover:-translate-y-1">
            {char}
        </span>
    )}
>
    Hello world
</TextSplit>

API

NameTypeDefaultDescription
childrenstring—Text to split. Required.
splitBy"letters" | "words""letters"How to split the string.
showMaskbooleantrueWrap each item in an overflow-clip span.
renderItems(char: string, index: number) => ReactNode—Custom render for each split item.
renderReactElement | function<span />Override the wrapper element.

No dependencies. This one runs on React and Tailwind CSS alone.

Star on githubBuy me a coffeellms.txt