Text Effects
pnpm dlx shadcn@latest add …/r/text-effects.jsonAccessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.
- Generating a reply…Shimmer
Ship it this afternoon
Reveal- Build forms, menus, dialogsTypewriter
import { TextReveal, TextShimmer, Typewriter } from "@/components/ui/text-effects"
export function Hero() {
return (
<>
<TextReveal as="h1">Ship it this afternoon</TextReveal>
<p>
Build <Typewriter phrases={["forms", "menus", "dialogs"]} />
</p>
<TextShimmer>Generating a reply…</TextShimmer>
</>
)
}import { TextReveal, TextShimmer, Typewriter } from "@/components/ui/text-effects";
/*
* The three effects side by side, named underneath. Replay plays the reveal and the typing again; the shimmer
* keeps sweeping.
*/
export default function TextEffectsOverview() {
return (
<ul className="grid w-full max-w-2xl grid-cols-1 items-end justify-items-center gap-x-10 gap-y-8 sm:grid-cols-3">
<li className="flex flex-col items-center gap-2">
<TextShimmer className="text-sm font-medium">Generating a reply…</TextShimmer>
<span className="text-xs text-muted-foreground">Shimmer</span>
</li>
<li className="flex flex-col items-center gap-2">
<TextReveal className="text-base font-semibold tracking-tight">Ship it this afternoon</TextReveal>
<span className="text-xs text-muted-foreground">Reveal</span>
</li>
<li className="flex flex-col items-center gap-2">
{/* The longest phrase, invisible, holds the width, so the line doesn't re-centre as it types */}
<span className="grid text-base font-semibold tracking-tight">
<span aria-hidden className="invisible col-start-1 row-start-1">
Build dialogs
</span>
<span className="col-start-1 row-start-1">
Build <Typewriter phrases={["forms", "menus", "dialogs"]} />
</span>
</span>
<span className="text-xs text-muted-foreground">Typewriter</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { useReducedMotion } from "motion/react"; import { type ComponentProps, Fragment, type HTMLAttributes, type Ref, type RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore, } from "react"; /* * Text Effects (owner's roadmap, 2026-10-16: "Shimmer, reveal and typewriter text"): three calm ways to bring * text in, each in one neutral hue and off with reduced motion. The words stay real text for search engines and * screen readers. * - TextShimmer: a sheen of `foreground` sweeps across text in `muted-foreground`, the lightness of one hue, * never a colour. A copy of the text in the light colour sits inside a soft-edged band (a static mask); the band * slides across while the copy slides back the same amount, so the copy stays put and only the light moves. * Transforms only, on the compositor, not an animated background. It keeps Skeleton's pace (a 1.5s sweep, a * 0.5s rest) and its clock, so a shimmering label sweeps in step with the skeletons beside it. * - TextReveal: words (or characters) rise 12px through the blur, 0.05s apart (motion.md's list items), once in * view or on mount. The pieces are aria-hidden; a visually hidden copy carries the text. They're hidden in the * server's HTML too, so a reveal above the fold never flashes in place before it plays. * - Typewriter: types each phrase, holds it, deletes it and types the next. The caret is solid while it types and * breathes (an eased fade, not a hard blink) while it waits; typing a phrase once, it fades away at the end. * Screen readers aren't sent the typing: a visually hidden copy says every phrase once, and nothing is a live * region, because announcing a phrase every few seconds, forever, would talk over the page. It rests while * off-screen. */ const subscribe = () => () => {}; /** False on the server and while hydrating, so the server's HTML is what hydrates */ const useHydrated = () => useSyncExternalStore( subscribe, () => true, () => false, ); const cubic = (curve: readonly number[]) => `cubic-bezier(${curve.join(", ")})`; /** Hands a node to our ref and to the caller's */ function useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) { return useCallback( (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) theirs.current = node; }, [own, theirs], ); } /* ----------------------------------------------------------------------------------------------------------- */ /** Skeleton's sheen: a 2s period, sweeping for the first 75% of it */ const SHIMMER_PERIOD = duration.slower * 4 * 1000; const SHIMMER_SWEEP = 0.75; /** The light is brightest in the band's middle and gone a quarter of the way in from each side */ const band = { maskImage: "linear-gradient(to right, transparent 25%, black, transparent 75%)", WebkitMaskImage: "linear-gradient(to right, transparent 25%, black, transparent 75%)", } as const; export type TextShimmerProps = ComponentProps<"span">; /** * Text with a light sweeping across it, for a label that says something is happening ("Generating…"). It takes * the text's size; the colours are `muted-foreground` with a `foreground` sheen. */ function TextShimmer({ className, children, ...props }: TextShimmerProps) { const reduce = useReducedMotion() ?? false; const light = useRef<HTMLSpanElement>(null); const copy = useRef<HTMLSpanElement>(null); useEffect(() => { const sweep = light.current; const still = copy.current; if (!sweep || !still || reduce || typeof sweep.animate !== "function") return; // From the start of the line in either writing direction const [from, to] = getComputedStyle(sweep).direction === "rtl" ? ["100%", "-100%"] : ["-100%", "100%"]; const back = (value: string) => (value.startsWith("-") ? value.slice(1) : `-${value}`); const timing = { duration: SHIMMER_PERIOD, iterations: Number.POSITIVE_INFINITY }; const easing = cubic(ease.inOut); const animations = [ sweep.animate( [{ translate: from, easing }, { translate: to, offset: SHIMMER_SWEEP }, { translate: to }], timing, ), // The copy goes back as far as the band goes on, so the letters never move still.animate( [ { translate: back(from), easing }, { translate: back(to), offset: SHIMMER_SWEEP }, { translate: back(to) }, ], timing, ), ]; // Counted from the page's start, like Skeleton's sheen, so every shimmer and skeleton sweeps together for (const animation of animations) animation.startTime = 0; return () => { for (const animation of animations) animation.cancel(); }; }, [reduce]); return ( <span data-slot="text-shimmer" className={cn("relative inline-block text-muted-foreground", className)} {...props} > {children} {/* The light: a copy of the text in the foreground colour, seen through a band that sweeps across */} <span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden select-none"> <span ref={light} className="absolute inset-0 -translate-x-full rtl:translate-x-full" style={band}> <span ref={copy} className="absolute inset-0 translate-x-full text-foreground rtl:-translate-x-full" > {children} </span> </span> </span> </span> ); } /* ----------------------------------------------------------------------------------------------------------- */ /** motion.md's list items: a 12px rise through the blur, 0.05s apart */ const REVEAL_RISE = "translateY(12px)"; const REVEAL_STAGGER = 0.05; type RevealTag = "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "li"; export type TextRevealProps = Omit<HTMLAttributes<HTMLElement>, "children"> & { /** The text to reveal */ children: string; /** The element to render, for example "h2" for a heading */ as?: RevealTag; /** Reveal word by word, or character by character (for short headings) */ by?: "word" | "character"; /** Seconds before the first piece rises */ delay?: number; /** Reveal once it scrolls into view. With false, it reveals on mount */ startOnView?: boolean; ref?: Ref<HTMLElement>; }; /** * Text that rises into place a word (or a character) at a time, once. Screen readers and search engines read the * whole text, as it is. */ function TextReveal({ children, as: Comp = "p", by = "word", delay = 0, startOnView = true, className, ref, ...props }: TextRevealProps) { const reduce = useReducedMotion() ?? false; const root = useRef<HTMLElement | null>(null); const setRef = useMergedRef(root, ref); const animations = useRef<Animation[]>([]); // Until it's revealed, every piece is held at opacity 0, from the server's HTML on const [shown, setShown] = useState(false); const settings = useRef({ delay, startOnView, reduce }); settings.current = { delay, startOnView, reduce }; useEffect(() => { const node = root.current; if (!node || shown) return; if (settings.current.reduce || typeof node.animate !== "function") { setShown(true); return; } const reveal = () => { const easing = cubic(ease.out); for (const animation of animations.current) animation.cancel(); animations.current = Array.from( node.querySelectorAll<HTMLElement>("[data-slot=text-reveal-piece]"), (piece, index) => piece.animate( [ { opacity: 0, filter: blur.hidden, transform: REVEAL_RISE }, { opacity: 1, filter: blur.shown, transform: "none" }, ], { duration: duration.slow * 1000, delay: Math.round((settings.current.delay + index * REVEAL_STAGGER) * 1000), easing, // Holds each piece hidden while it waits; at the end it lets go, leaving no filter behind fill: "backwards", }, ), ); // The pieces drop their inline opacity in the same frame, and the animations take over setShown(true); }; if (!settings.current.startOnView || typeof IntersectionObserver === "undefined") { reveal(); return; } const observer = new IntersectionObserver((entries) => { if (!entries.some((entry) => entry.isIntersecting)) return; observer.disconnect(); reveal(); }); observer.observe(node); return () => observer.disconnect(); }, [shown]); useEffect( () => () => { for (const animation of animations.current) animation.cancel(); }, [], ); const hidden = shown ? undefined : { opacity: 0 }; const words = children.split(/\s+/).filter(Boolean); return ( <Comp ref={setRef} data-slot="text-reveal" className={className} {...props}> <span className="sr-only select-none">{children}</span> <span aria-hidden data-slot="text-reveal-text"> {words.map((word, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: the same word can come twice; its place is its identity <Fragment key={index}> {index > 0 ? " " : null} {by === "word" ? ( <span data-slot="text-reveal-piece" className="inline-block" style={hidden}> {word} </span> ) : ( // A word's characters never break apart across lines <span className="inline-block whitespace-nowrap"> {Array.from(word).map((char, place) => ( // biome-ignore lint/suspicious/noArrayIndexKey: characters repeat; their place is their identity <span key={place} data-slot="text-reveal-piece" className="inline-block" style={hidden}> {char} </span> ))} </span> )} </Fragment> ))} </span> </Comp> ); } /* ----------------------------------------------------------------------------------------------------------- */ /** The caret's breath while it waits: in and out over a second */ const CARET_BREATH = duration.slower * 2 * 1000; type Typing = { phrase: number; length: number; /** starting: before the first character · between: after deleting, before the next phrase */ mode: "starting" | "typing" | "holding" | "deleting" | "between" | "done"; }; export type TypewriterProps = Omit<ComponentProps<"span">, "children"> & { /** One phrase, or several to type in turn */ phrases: string | readonly string[]; /** Go back to the first phrase after the last. On by default for several phrases, off for one */ loop?: boolean; /** Seconds per character typed */ typeSpeed?: number; /** Seconds per character deleted */ deleteSpeed?: number; /** Seconds a typed phrase stays before it's deleted */ hold?: number; /** Seconds before the first character, once it's in view */ delay?: number; /** Show the caret */ cursor?: boolean; }; /** * Types a phrase, or several in turn, with a soft caret. It starts once it's in view and rests while it's * off-screen. Screen readers hear every phrase once, as plain text. */ function Typewriter({ phrases, loop, typeSpeed = duration.instant, deleteSpeed = duration.instant / 2, hold = duration.slower * 4, delay = 0, cursor = true, className, ref, ...props }: TypewriterProps) { const list = typeof phrases === "string" ? [phrases] : [...phrases]; const loops = loop ?? list.length > 1; const reduce = useReducedMotion() ?? false; // With reduced motion the first phrase is simply there; the server's HTML (still empty) hydrates first const still = useHydrated() && reduce; const root = useRef<HTMLSpanElement | null>(null); const setRef = useMergedRef(root, ref); const caret = useRef<HTMLSpanElement>(null); const caretAnimation = useRef<Animation | null>(null); const [typing, setTyping] = useState<Typing>({ phrase: 0, length: 0, mode: "starting" }); const [inView, setInView] = useState(false); const settings = useRef({ list, loops, typeSpeed, deleteSpeed, hold, delay }); settings.current = { list, loops, typeSpeed, deleteSpeed, hold, delay }; useEffect(() => { const node = root.current; if (!node || typeof IntersectionObserver === "undefined") { setInView(true); return; } const observer = new IntersectionObserver((entries) => { const entry = entries.at(-1); if (entry) setInView(entry.isIntersecting); }); observer.observe(node); return () => observer.disconnect(); }, []); // One step at a time: a character typed or deleted, a hold, a pause between phrases useEffect(() => { if (still || !inView || typing.mode === "done") return; const { list, loops, typeSpeed, deleteSpeed, hold, delay } = settings.current; const total = Array.from(list[typing.phrase] ?? "").length; const last = typing.phrase >= list.length - 1; let wait: number; let next: Typing; switch (typing.mode) { case "starting": case "between": wait = typing.mode === "starting" ? delay : duration.slow; next = { ...typing, mode: total > 0 ? "typing" : "holding" }; break; case "typing": { const length = typing.length + 1; wait = typeSpeed; next = { ...typing, length, mode: length >= total ? "holding" : "typing" }; break; } case "holding": wait = hold; next = last && !loops ? { ...typing, mode: "done" } : { ...typing, mode: "deleting" }; break; default: { const length = typing.length - 1; wait = deleteSpeed; next = length > 0 ? { ...typing, length } : { phrase: last ? 0 : typing.phrase + 1, length: 0, mode: "between" }; } } const timer = setTimeout(() => setTyping(next), wait * 1000); return () => clearTimeout(timer); }, [typing, inView, still]); // The caret: solid while the text moves, breathing while it waits, fading away when it's done const caretMode = typing.mode === "typing" || typing.mode === "deleting" ? "solid" : typing.mode === "done" ? "gone" : "wait"; useEffect(() => { const node = caret.current; if (!node || typeof node.animate !== "function") return; // Read before cancelling, so the fade at the end starts from wherever the breath was const opacity = getComputedStyle(node).opacity || "1"; caretAnimation.current?.cancel(); caretAnimation.current = null; if (caretMode === "wait") { caretAnimation.current = node.animate([{ opacity: 1 }, { opacity: 0 }, { opacity: 1 }], { duration: CARET_BREATH, iterations: Number.POSITIVE_INFINITY, easing: cubic(ease.inOut), }); } else if (caretMode === "gone") { // The end state, kept until it unmounts caretAnimation.current = node.animate([{ opacity }, { opacity: 0 }], { duration: duration.slow * 1000, easing: cubic(ease.out), fill: "forwards", }); } }, [caretMode]); useEffect(() => () => caretAnimation.current?.cancel(), []); const typed = still ? (list[0] ?? "") : Array.from(list[typing.phrase] ?? "") .slice(0, typing.length) .join(""); return ( <span ref={setRef} data-slot="typewriter" className={cn("whitespace-pre-wrap", className)} {...props}> <span className="sr-only select-none">{list.join(", ")}</span> <span aria-hidden data-slot="typewriter-text"> {typed} </span> {cursor && !still ? ( // A 2px bar one line of the text tall: the zero-width space gives it the font's height and baseline <span ref={caret} aria-hidden data-slot="typewriter-caret" className="ms-0.5 inline-block w-0.5 rounded-full bg-current leading-none select-none" > {""} </span> ) : null} </span> ); } export { TextReveal, TextShimmer, Typewriter };Update the import paths to match your project.
Examples
Reveal by character
by="character" for a short line. Each word stays whole across a line break.
Calm by default
import { TextReveal } from "@/components/ui/text-effects";
/** Character by character, for a short line. Each word stays whole across a line break. */
export default function TextRevealByCharacter() {
return (
<TextReveal by="character" className="text-base font-semibold tracking-tight">
Calm by default
</TextReveal>
);
}Type once
One phrase types once and stays; the caret breathes for a moment, then fades away.
import { Typewriter } from "@/components/ui/text-effects";
/** One phrase, typed once: the caret breathes for a moment, then fades away. */
export default function TypewriterOnce() {
return <Typewriter phrases="Your workspace is ready." className="text-sm" />;
}API reference
TextShimmer
A span whose text is muted-foreground, with a foreground sheen sweeping across it every 2s, in step with Skeleton's sheen. It stops with reduced motion. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|---|---|---|
children? | ReactNode | – | The text. Keep it to text: it's drawn twice. |
TextReveal
Reveals its text once, a word or a character at a time. A visually hidden copy carries the text for screen readers and search engines. With reduced motion it's simply there. Also accepts every native prop of its element.
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | – | The text to reveal. |
as? | "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "li" | "p" | The element to render, for example h2 for a heading. |
by? | "word" | "character" | "word" | Reveal word by word, or character by character for a short line. |
delay? | number | 0 | Seconds before the first piece rises. |
startOnView? | boolean | true | Reveal once it scrolls into view. With false, it reveals as soon as it mounts. |
Typewriter
Types its phrases in turn. It starts once it's in view and rests off-screen. Screen readers hear every phrase once, as plain text, and never the typing; nothing is announced as it changes. With reduced motion the first phrase is simply there. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|---|---|---|
phrases | string | string[] | – | One phrase, or several to type in turn. |
loop? | boolean | – | Start over after the last phrase. On by default for several phrases, off for one. |
typeSpeed? | number | 0.08 | Seconds per character typed. |
deleteSpeed? | number | 0.04 | Seconds per character deleted. |
hold? | number | 2 | Seconds a typed phrase stays before it's deleted. |
delay? | number | 0 | Seconds before the first character. |
cursor? | boolean | true | Show the caret. It fades away once a phrase typed only once is done. |
Related components
- 12,480Users
An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.
- Loading profile
A React skeleton loader for Next.js and Tailwind CSS: placeholder blocks while content loads, with a calm sheen that sweeps every skeleton in step.
- NorthwindATLASorbitVertexQuill
An accessible React marquee for Next.js and Tailwind CSS: an endless, seamless row of logos or cards that pauses on hover and stops with reduced motion.