Text Appear
A heading whose words rise into place out of a blur, one after another.
From the Titen template — see it in contextBuild the thing people remember
A studio for brands that would rather be useful than loud.
Press Replay to watch it again.
import { TextAppear } from "@/components/ui/text-appear";
export default function TextAppearDemo() {
return (
<div className="flex w-full max-w-lg flex-col gap-4 text-center">
<TextAppear
as="h2"
text="Build the thing people remember"
className="text-2xl font-semibold tracking-tight text-balance sm:text-3xl"
/>
<TextAppear
by="line"
delay={0.4}
text="A studio for brands that would rather be useful than loud."
className="text-sm text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">Press Replay to watch it again.</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/text-appear.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/text-appear.tsx."use client"; import * as React from "react"; import { motion, useReducedMotion, type Transition } from "motion/react"; /* * Text Appear — a heading whose words rise into place out of a blur, one after another (from the Titen headings; * Ciketo has the same effect without the blur, which `blur={0}` gives). * It plays once: on load, or the first time it scrolls into view. * * Framer's springs are overdamped — no overshoot, settled in well under a second: * words: from opacity 0, y 10, blur 7px, spring 320 / 58 / 1, each word 0.05s after the one before; * lines: from y 7, spring 320 / 64 / 1 — `by="line"` moves the whole text at once. * Framer splits the text into letters, which breaks the kerning; whole words keep it. * * Reduced motion: <MotionConfig reducedMotion="user"> skips the movement but would keep animating opacity and blur, * so this checks useReducedMotion() itself and shows the text at once. * Screen readers get the sentence once, from an sr-only copy; the animated words are aria-hidden. * The words start at opacity 0 on the server, so the text appears when the page hydrates — keep `delay` short. */ const word: Transition = { type: "spring", stiffness: 320, damping: 58, mass: 1 }; const line: Transition = { type: "spring", stiffness: 320, damping: 64, mass: 1 }; export type TextAppearProps = { /** The text. It is split on spaces, so the kerning inside a word is never touched. */ text: string; /** The element to render: a heading, a paragraph, anything. */ as?: React.ElementType; /** "word" moves each word in turn; "line" moves the whole text at once. */ by?: "word" | "line"; /** "load" plays as soon as it mounts; "view" waits until it scrolls into view. Either way, once. */ trigger?: "load" | "view"; /** Seconds before it starts. */ delay?: number; /** Seconds between words. */ stagger?: number; /** Blur it starts from, in px; 0 turns the blur off (Ciketo). */ blur?: number; /** How far it rises, in px. */ distance?: number; className?: string; }; export function TextAppear({ text, as: Tag = "p", by = "word", trigger = "load", delay = 0, stagger = 0.05, blur = 7, distance, className, }: TextAppearProps) { const reduce = useReducedMotion(); const words = React.useMemo(() => text.split(" "), [text]); const rise = distance ?? (by === "word" ? 10 : 7); if (reduce) { return <Tag className={className}>{text}</Tag>; } const hidden = { opacity: 0, y: rise, filter: blur ? `blur(${blur}px)` : "blur(0px)" }; const shown = { opacity: 1, y: 0, filter: "blur(0px)" }; const inView = trigger === "view" ? { whileInView: "shown", viewport: { once: true, amount: 0.3 } } : { animate: "shown" }; /* The element stays whatever `as` says; the orchestration sits on a span inside it, so no component is built during render (and the heading keeps its own styles). */ return ( <Tag className={className}> {/* The sentence, read once. */} <span className="sr-only">{text}</span> <motion.span aria-hidden initial="hidden" {...inView} variants={{ shown: { transition: { delayChildren: delay, staggerChildren: by === "word" ? stagger : 0 } } }} className="inline-block" > {by === "line" ? ( <motion.span variants={{ hidden, shown }} transition={line} className="inline-block"> {text} </motion.span> ) : ( words.map((part, index) => ( <React.Fragment key={`${part}-${index}`}> <motion.span variants={{ hidden, shown }} transition={word} className="inline-block"> {part} </motion.span> {index < words.length - 1 && " "} </React.Fragment> )) )} </motion.span> </Tag> ); }
Usage
import { TextAppear } from "@/components/ui/text-appear";
<TextAppear as="h1" text="Build the thing people remember" className="text-4xl font-semibold" />
<TextAppear by="line" delay={0.4} text="A studio for brands that would rather be useful than loud." />
// Ciketo's version: no blur, a touch slower between words
<TextAppear text="Design that earns its keep" blur={0} stagger={0.075} trigger="view" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text. It is split on spaces, so the kerning inside a word is never touched. |
| as | ElementType | "p" | The element to render: a heading, a paragraph, anything. |
| by | "word" | "line" | "word" | Move each word in turn, or the whole text at once. |
| trigger | "load" | "view" | "load" | Play on mount, or the first time it scrolls into view. Either way, once. |
| delay | number | 0 | Seconds before it starts. |
| stagger | number | 0.05 | Seconds between words. |
| blur | number | 7 | Blur it starts from, in px; 0 turns the blur off. |
| distance | number | 10 / 7 | How far it rises, in px (10 by word, 7 by line). |
- Screen readers get the sentence once, from a visually hidden copy; the animated words are aria-hidden.
- With reduced motion the text is simply there: MotionConfig would skip the movement but keep animating opacity and blur, so the component checks for itself.
- The words start at opacity 0 on the server, so the text appears when the page hydrates — keep `delay` short on anything above the fold.
- Framer splits text into letters, which breaks the kerning. This animates whole words, and `by="line"` moves the lot.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called TextAppear.
- Split the text on spaces into inline-block spans, keeping the spaces as text between them so it still wraps.
- Each word animates from opacity 0, y 10 and blur 7px to rest, with a spring of stiffness 320, damping 58, mass 1,
each word 0.05s after the one before (delayChildren for the initial delay, staggerChildren for the rest).
- by="line" moves the whole text as one span instead, from y 7, spring 320 / 64 / 1.
- trigger="load" animates on mount; trigger="view" uses whileInView with { once: true, amount: 0.3 }.
- Put the orchestration on a span inside the element the caller asked for, so no component is created during render.
- A visually hidden copy of the text carries it for screen readers; the animated spans are aria-hidden.
- With useReducedMotion() true, render the plain text — MotionConfig would still animate opacity and blur.










