Headlines that arrive word by word or letter by letter: slide, blur, hinge or rise from behind a mask, from any direction.
Make every word land.
Customize
Usage
import { KineticHeadline } from "@/components/motionkit/kinetic-headline";
<KineticHeadline
as="h1"
text="Make every word land."
className="text-6xl 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-headline.tsx.
"use client";
import { Fragment, type ElementType } from "react";
import { motion, useReducedMotion, type TargetAndTransition } from "motion/react";
type Effect = "slide" | "blur" | "rotate" | "mask";
type Direction = "up" | "down" | "left" | "right";
type KineticHeadlineProps = {
text: string;
/** Animate whole words, or every character. */
split?: "words" | "chars";
/**
* slide: pieces travel in · blur: they sharpen into focus · rotate: they swing up on a hinge ·
* mask: they rise from behind an invisible edge.
*/
effect?: Effect;
/** Where each piece comes from. */
direction?: Direction;
/** Seconds each piece takes to arrive. */
duration?: number;
/** Seconds between one piece and the next. */
stagger?: number;
/** Which end the stagger ripples out from. */
staggerFrom?: "start" | "center" | "end";
/** "view" plays when scrolled into view; "load" plays straight away. */
trigger?: "view" | "load";
/** With trigger="view", play only the first time rather than every time it comes back into view. */
once?: boolean;
/** Element to render, e.g. "h1". */
as?: ElementType;
className?: string;
};
const ease = [0.22, 1, 0.36, 1] as const;
// the offset a piece starts from, for each direction
const offsets: Record<Direction, { x?: string; y?: string }> = {
up: { y: "0.6em" },
down: { y: "-0.6em" },
left: { x: "0.6em" },
right: { x: "-0.6em" },
};
const maskOffsets: Record<Direction, { x?: string; y?: string }> = {
up: { y: "105%" },
down: { y: "-105%" },
left: { x: "105%" },
right: { x: "-105%" },
};
function hiddenState(effect: Effect, direction: Direction): TargetAndTransition {
const vertical = direction === "up" || direction === "down";
switch (effect) {
case "slide":
return { opacity: 0, ...offsets[direction] };
case "blur":
return { opacity: 0, filter: "blur(14px)", scale: 1.08, ...offsets[direction] };
case "rotate":
// a hinge along the edge the piece comes from, so it swings into place
return vertical
? { opacity: 0, rotateX: direction === "up" ? -95 : 95 }
: { opacity: 0, rotateY: direction === "left" ? 95 : -95 };
case "mask":
return maskOffsets[direction];
}
}
const shownState: TargetAndTransition = { opacity: 1, x: 0, y: 0, filter: "blur(0px)", scale: 1, rotateX: 0, rotateY: 0 };
function delayFor(index: number, count: number, from: "start" | "center" | "end") {
if (from === "end") return count - 1 - index;
if (from === "center") return Math.abs(index - (count - 1) / 2);
return index;
}
export function KineticHeadline({
text,
split = "words",
effect = "mask",
direction = "up",
duration = 0.8,
stagger = 0.06,
staggerFrom = "start",
trigger = "view",
once = true,
as: Tag = "h2",
className = "",
}: KineticHeadlineProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) return <Tag className={className}>{text}</Tag>;
const words = text.split(" ");
const pieceCount = split === "words" ? words.length : words.join("").length;
const hidden = hiddenState(effect, direction);
const origin =
effect !== "rotate" ? undefined : { up: "50% 100%", down: "50% 0%", left: "100% 50%", right: "0% 50%" }[direction];
let index = 0;
const piece = (content: string, key: string) => {
const delay = delayFor(index++, pieceCount, staggerFrom) * stagger;
const animation = {
variants: { hidden, shown: shownState },
transition: { duration, delay, ease },
};
const inner = (
<motion.span
key={effect === "mask" ? undefined : key}
data-piece
className="inline-block"
style={{ transformOrigin: origin }}
{...animation}
>
{content}
</motion.span>
);
// the mask is a clipping box around each piece; padding keeps descenders and accents inside it
return effect === "mask" ? (
<span key={key} className="-mb-[0.12em] inline-block overflow-hidden pb-[0.12em] align-bottom">
{inner}
</span>
) : (
inner
);
};
return (
<Tag className={className}>
{/* screen readers read the headline once, not piece by piece */}
<span className="sr-only">{text}</span>
<motion.span
aria-hidden
className="inline"
initial="hidden"
animate={trigger === "load" ? "shown" : undefined}
whileInView={trigger === "view" ? "shown" : undefined}
viewport={{ once, amount: 0.5 }}
>
{words.map((word, w) => (
<Fragment key={w}>
{/* words never break across lines, even when split into characters */}
{/* rotate gets its depth from the word box, so a settled letter has no transform left and stays crisp */}
<span className={`inline-block whitespace-nowrap ${effect === "rotate" ? "[perspective:600px]" : ""}`}>
{split === "words" ? piece(word, `w${w}`) : Array.from(word).map((char, c) => piece(char, `w${w}c${c}`))}
</span>
{/* the space sits between the boxes, where the browser can wrap the line */}
{w < words.length - 1 && " "}
</Fragment>
))}
</motion.span>
</Tag>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The headline. |
| split | "words" | "chars" | "words" | Animate whole words or every character. |
| effect | "slide" | "blur" | "rotate" | "mask" | "mask" | How each piece arrives. |
| direction | "up" | "down" | "left" | "right" | "up" | Where each piece comes from. |
| duration | number | 0.8 | Seconds each piece takes. |
| stagger | number | 0.06 | Seconds between pieces. |
| staggerFrom | "start" | "center" | "end" | "start" | Which end the stagger ripples out from. |
| trigger | "view" | "load" | "view" | Play on scroll into view, or straight away. |
| once | boolean | true | With trigger="view", play only the first time. |
| as | ElementType | "h2" | Element to render. |
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.
Monthly revenue
Number Ticker
Counts up to a value with an eased finish when it scrolls into view.
Build faster, smarter, together
Morphing Text
Rotates through a list of words, letters rising in and drifting out.