Text Animate
Animates text in by word or character with configurable effects. Install Text Animate from the VLLNT UI registry with the shadcn CLI.
Animates text in by word or character with configurable effects. Part of the content family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Text Animate to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/text-animate.jsonSource
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
/** Per-segment entrance animation. */
export type TextAnimateAnimation = "blur" | "fade" | "slide-up";
/** Props for {@link TextAnimate}. */
export type TextAnimateProps = React.ComponentPropsWithoutRef<"div"> & {
/** Entrance style. Defaults to `"fade"`. */
animation?: TextAnimateAnimation;
/** Split granularity. Defaults to `"word"`. */
by?: "character" | "word";
/** Text split into animated segments. */
children: string;
/** Milliseconds of stagger between segments. Defaults to `60`. */
delay?: number;
/** Wait until scrolled into view before animating. Defaults to `true`. */
startOnView?: boolean;
};
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return;
}
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (): void => {
setReduced(query.matches);
};
onChange();
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
};
}, []);
return reduced;
}
function splitText(text: string, by: "character" | "word"): string[] {
if (by === "character") {
return text.match(/[\s\S]/gu) ?? [];
}
return text.split(/(\s+)/).filter((segment) => segment.length > 0);
}
function useInView(
enabled: boolean,
): [React.RefObject<HTMLDivElement | null>, boolean] {
const nodeRef = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(!enabled);
React.useEffect(() => {
const node = nodeRef.current;
if (!enabled || !node || typeof IntersectionObserver !== "function") {
setInView(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setInView(true);
observer.disconnect();
}
});
observer.observe(node);
return () => {
observer.disconnect();
};
}, [enabled]);
return [nodeRef, inView];
}
function assignRef(
ref: React.Ref<HTMLDivElement> | undefined,
node: HTMLDivElement | null,
): void {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
}
const animationClasses: Record<TextAnimateAnimation, string> = {
blur: "[filter:blur(6px)] opacity-0",
fade: "animate-in fade-in-0",
"slide-up": "animate-in fade-in-0 slide-in-from-bottom-2",
};
function Segment({
animation,
delay,
index,
inView,
value,
}: {
animation: TextAnimateAnimation;
delay: number;
index: number;
inView: boolean;
value: string;
}) {
const blur = animation === "blur";
return (
<span
className={cn(
"inline-block whitespace-pre",
blur
? cn(
"transition-[filter,opacity] duration-500",
inView ? "opacity-100 [filter:blur(0)]" : animationClasses.blur,
)
: inView && cn("fill-mode-both", animationClasses[animation]),
)}
style={{ animationDelay: `${index * delay}ms` }}
>
{value}
</span>
);
}
/**
* Reveals text segment-by-segment with a staggered entrance.
*
* Respects `prefers-reduced-motion`: the text shows at once.
*
* @example
* ```tsx
* <TextAnimate animation="slide-up">Welcome aboard</TextAnimate>
* ```
*/
export const TextAnimate = ({
animation = "fade",
by = "word",
children,
className,
delay = 60,
ref,
startOnView = true,
...props
}: TextAnimateProps & { ref?: React.Ref<HTMLDivElement> }) => {
const reduced = usePrefersReducedMotion();
const [nodeRef, inView] = useInView(startOnView);
const segments = splitText(children, by);
const visible = reduced || inView;
return (
<div
className={cn(className)}
ref={(node) => {
nodeRef.current = node;
assignRef(ref, node);
}}
{...props}
>
<span aria-hidden="true">
{segments.map((value, index) => (
<Segment
animation={reduced ? "fade" : animation}
delay={reduced ? 0 : delay}
index={index}
inView={visible}
key={`${value}-${index}`}
value={value}
/>
))}
</span>
<span className="sr-only">{children}</span>
</div>
);
};
TextAnimate.displayName = "TextAnimate";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.