77 / Motion
Split Text
A heading animated one character, word, or line at a time, and still announced as one sentence rather than as a pile of single letters.
"use client"
import { DemoVariants } from "@/components/demos/demo-variants"
import {
SplitText,
type SplitAnimation,
} from "mischief-ui/split-text"
const animations: SplitAnimation[] = ["rise", "fade", "blur", "scale"]
export function SplitTextDemo() {
return (
<DemoVariants
label="Animation"
variants={
animations.map((animation) => ({
id: animation,
label: animation,
render: () => (
<p className="text-center text-3xl font-semibold text-balance">
<SplitText
key={animation}
animation={animation}
trigger="mount"
by="character"
>
Good interfaces deserve a little mischief
</SplitText>
</p>
),
})) as [
{ id: string; label: string; render: () => React.ReactNode },
...{ id: string; label: string; render: () => React.ReactNode }[],
]
}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/split-textimport { SplitText } from "mischief-ui/split-text"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type SplitBy = "character" | "word" | "line" export type SplitAnimation = "rise" | "fade" | "blur" | "scale" export type SplitTextProps = Omit< React.HTMLAttributes<HTMLSpanElement>, "children"> & { children: stringUsage
export function Headline() {
return (
<h1 className="text-5xl font-semibold">
<SplitText by="character" animation="rise">
Good interfaces deserve a little mischief
</SplitText>
</h1>
)
}Announced as a sentence
Splitting a heading into one element per letter is what makes this effect possible and is also what usually ruins it. A screen reader handed forty single-letter elements may read forty letters.
So the whole string is set as the label on the element, and every piece inside is hidden from assistive technology. What is announced is the sentence you wrote. What is animated is the letters. Neither knows about the other.
Which split to use
Character is the showiest and the most expensive: a forty character heading is forty elements each with its own transition. It suits one large heading and does not suit a paragraph.
Word is the one to reach for at body size. Line is for something already broken into lines, and splits on the newlines in the string rather than trying to work out where the browser wrapped it.
Whitespace is never animated, whichever split you choose, so a gap between two words does not fade in and change the measure while the rest arrives.
Getting the timing right
The total is the stagger times the number of pieces plus the duration of one piece. At the default stagger a forty character heading takes a little over a second and a half, which is about as long as an entrance can be before it stops feeling like an entrance.
<SplitText by="word" stagger={60} animation="blur">
Long enough to read while it arrives
</SplitText>API
childrenstringThe text. A string, because it has to be split.by"character" | "word" | "line"What each piece is. Defaults to "character".animation"rise" | "fade" | "blur" | "scale"How a piece arrives. Defaults to "rise".staggernumberMilliseconds between one piece and the next. Defaults to 28.delaynumberMilliseconds before the first piece.durationnumberMilliseconds for one piece. Defaults to 620.trigger"mount" | "view"Whether it plays on arrival or once on screen. Defaults to "view".as"span" | "h1" | "h2" | "h3" | "p"The element rendered. Defaults to "span".Accessibility
The full string is the element's label and every piece is hidden, so the text is announced once, as written. Under reduced motion every piece is at rest from the first paint and nothing moves, fades, or blurs. The text is in the document whether or not the animation ever runs.