A foundation block that splits a string into letters or words.
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.
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.jsonInstall the dependencies first, then feel free to copy the files into your project as you see fit.
"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 },
})
}
// 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:
<TextSplit>Hello world</TextSplit>Use splitBy="words" to split on spaces instead:
<TextSplit splitBy="words">Hello world</TextSplit>renderItems lets you render each piece yourself. Here, every letter lifts on hover:
<TextSplit
renderItems={(char, i) => (
<span key={i} className="inline-block transition-transform hover:-translate-y-1">
{char}
</span>
)}
>
Hello world
</TextSplit>| Name | Type | Default | Description |
|---|---|---|---|
children | string | — | Text to split. Required. |
splitBy | "letters" | "words" | "letters" | How to split the string. |
showMask | boolean | true | Wrap each item in an overflow-clip span. |
renderItems | (char: string, index: number) => ReactNode | — | Custom render for each split item. |
render | ReactElement | function | <span /> | Override the wrapper element. |
No dependencies. This one runs on React and Tailwind CSS alone.