Headlines that rise letter by letter, slide up line by line, blur into focus or light up as you scroll.
Small details, big impact.
Customize
Usage
import { KineticText } from "@/components/motionkit/kinetic-text";
<KineticText
as="h1"
text="Small details,\nbig impact."
className="text-5xl font-semibold"
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Each piece
Scrolls into view
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/kinetic-text.tsx.
"use client";
import { useRef, type ElementType } from "react";
import { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from "motion/react";
type KineticTextProps = {
/** The text. In "lines" mode, separate lines with \n. */
text: string;
/**
* letters: each letter rises in turn · lines: each line slides up from behind a mask ·
* blur: words sharpen into focus · scroll: letters light up as you scroll past.
*/
mode?: "letters" | "lines" | "blur" | "scroll";
/** Seconds between each letter, word or line. */
stagger?: number;
/** Seconds each piece takes to arrive. */
duration?: number;
/** Element to render, e.g. "h1". */
as?: ElementType;
className?: string;
};
const ease = [0.22, 1, 0.36, 1] as const;
export function KineticText({
text,
mode = "letters",
stagger = 0.03,
duration = 0.6,
as: Tag = "p",
className = "",
}: KineticTextProps) {
const reduceMotion = useReducedMotion();
const flat = text.replace(/\n/g, " ");
if (reduceMotion) return <Tag className={className}>{flat}</Tag>;
return (
<Tag className={className}>
{/* screen readers read the sentence once, not letter by letter */}
<span className="sr-only">{flat}</span>
<span aria-hidden>
{mode === "letters" && <Letters text={flat} stagger={stagger} duration={duration} />}
{mode === "lines" && <Lines text={text} stagger={stagger} duration={duration} />}
{mode === "blur" && <Blur text={flat} stagger={stagger} duration={duration} />}
{mode === "scroll" && <ScrollReveal text={flat} />}
</span>
</Tag>
);
}
type PartProps = { text: string; stagger: number; duration: number };
function Letters({ text, stagger, duration }: PartProps) {
let count = 0;
// words stay unbroken so lines only wrap between them
return text.split(" ").map((word, w) => (
<span key={w} className="inline-block whitespace-nowrap">
{Array.from(word).map((char, c) => (
<motion.span
key={c}
className="inline-block"
initial={{ opacity: 0, y: "0.5em", rotate: 8 }}
whileInView={{ opacity: 1, y: 0, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration, delay: count++ * stagger, ease }}
>
{char}
</motion.span>
))}
{" "}
</span>
));
}
function Lines({ text, stagger, duration }: PartProps) {
return text.split("\n").map((line, index) => (
// the clip hides each line until it slides up into place
<span key={index} className="block overflow-hidden pb-[0.08em]">
<motion.span
className="block"
initial={{ y: "110%" }}
whileInView={{ y: 0 }}
viewport={{ once: true }}
transition={{ duration, delay: index * stagger * 4, ease }}
>
{line}
</motion.span>
</span>
));
}
function Blur({ text, stagger, duration }: PartProps) {
return text.split(" ").map((word, index) => (
<motion.span
key={index}
className="inline-block"
initial={{ opacity: 0, filter: "blur(12px)", scale: 1.06 }}
whileInView={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
viewport={{ once: true }}
transition={{ duration: duration * 1.4, delay: index * stagger * 3, ease }}
>
{word}
{" "}
</motion.span>
));
}
function ScrollReveal({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
// 0 as the text enters the lower part of the screen, 1 once it reaches the upper third
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.9", "start 0.35"] });
const chars = Array.from(text);
return (
<span ref={ref}>
{chars.map((char, index) => (
<ScrollChar key={index} char={char} progress={scrollYProgress} range={[index / chars.length, (index + 1) / chars.length]} />
))}
</span>
);
}
function ScrollChar({ char, progress, range }: { char: string; progress: MotionValue<number>; range: [number, number] }) {
const opacity = useTransform(progress, range, [0.15, 1]);
return <motion.span style={{ opacity }}>{char}</motion.span>;
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text. In lines mode, separate lines with \n. |
| mode | "letters" | "lines" | "blur" | "scroll" | "letters" | How the text arrives. |
| stagger | number | 0.03 | Seconds between each letter, word or line. |
| duration | number | 0.6 | Seconds each piece takes to arrive. |
| as | ElementType | "p" | Element to render, e.g. "h1". |
Small details make the whole thing feel alive.
Word Rise
Words lift out of a blur one after another as the line scrolls into view.
We cut load time by 62% in six weeks.
Redacted Reveal
Bars cover the key facts like a classified file, then lift one by one on scroll, hover or click.
We build
design systems, landing pages, online stores
Typewriter
Types a phrase, pauses, backspaces and types the next, with a human rhythm and a caret that blinks only at rest.