"use client";
import * as React from "react";
export type KineticHeadlineMode = "line" | "word" | "char";
export type KineticHeadlineTrigger = "mount" | "view";
export type KineticHeadlineEasing = "expo" | "back" | "smooth";
export interface KineticHeadlineProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** The headline text. Use "\n" to force a line break. */
children: string;
/** Element to render. */
as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";
/** Reveal unit: whole lines (measured from the real layout), words or characters. */
mode?: KineticHeadlineMode;
/** Play on mount, or once the headline scrolls into view. */
trigger?: KineticHeadlineTrigger;
/** Duration of each fragment's reveal, in ms. */
duration?: number;
/** Delay between consecutive fragments, in ms. */
stagger?: number;
/** Delay before the first fragment, in ms. */
delay?: number;
/** Timing curve of the reveal. */
easing?: KineticHeadlineEasing;
/** Fade fragments in from a soft blur as they rise. */
blur?: boolean;
}
const EASINGS: Record<KineticHeadlineEasing, string> = {
expo: "cubic-bezier(0.16, 1, 0.3, 1)",
back: "cubic-bezier(0.34, 1.56, 0.64, 1)",
smooth: "cubic-bezier(0.65, 0, 0.35, 1)",
};
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* --------------------------------- layout --------------------------------- */
interface Word {
text: string;
/** Running index of the word across the whole headline. */
index: number;
/** Running index of the word's first character. */
charStart: number;
}
/** Splits text into forced lines (\n) and words, keeping running indexes for staggering. */
function splitText(text: string): Word[][] {
let wordIndex = 0;
let charIndex = 0;
return text.split("\n").map((line) =>
line
.split(/\s+/)
.filter(Boolean)
.map((word) => {
const entry = { text: word, index: wordIndex, charStart: charIndex };
wordIndex += 1;
charIndex += Array.from(word).length;
return entry;
}),
);
}
// Every moving fragment shares one transition; its delay comes from the inherited --kh-i index.
const FRAGMENT =
"inline-block [will-change:translate] [transition-property:translate,opacity,filter] " +
"[transition-duration:var(--kh-duration)] [transition-timing-function:var(--kh-ease)] " +
"[transition-delay:calc(var(--kh-delay)+var(--kh-i,0)*var(--kh-stagger))] " +
"group-data-[state=out]/kh:translate-y-[110%]";
const FRAGMENT_BLUR = "group-data-[state=out]/kh:opacity-0 group-data-[state=out]/kh:blur-[6px]";
/**
* Large display type revealed line by line, word by word or character by character,
* each fragment rising out of its own mask.
*
* The animation plays once. To replay it, remount the component by changing its `key`.
*/
export function KineticHeadline({
children,
as = "h2",
mode = "word",
trigger = "mount",
duration = 900,
stagger = 60,
delay = 0,
easing = "expo",
blur = false,
className = "",
style,
...props
}: KineticHeadlineProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const [revealed, setRevealed] = React.useState(false);
const Tag = as as React.ElementType;
// Start the reveal on mount or when the headline enters the viewport (once).
React.useEffect(() => {
const root = rootRef.current;
if (reduceMotion || !root) return;
let frame = 0;
// Two frames guarantee the hidden state has been painted, so the transition always runs.
const play = () => {
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => setRevealed(true));
});
};
if (trigger === "mount") {
play();
return () => cancelAnimationFrame(frame);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
play();
}
},
{ threshold: 0.35 },
);
observer.observe(root);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [trigger, reduceMotion]);
// In line mode, group words by their rendered line and give each mask its line index.
// Re-measured on resize so the stagger always follows the real wrapping.
React.useLayoutEffect(() => {
const root = rootRef.current;
if (mode !== "line" || reduceMotion || !root) return;
const measure = () => {
const masks = root.querySelectorAll<HTMLElement>("[data-kh-mask]");
let line = -1;
let lastTop = -Infinity;
masks.forEach((mask) => {
if (mask.offsetTop > lastTop + 2) {
line += 1;
lastTop = mask.offsetTop;
}
mask.style.setProperty("--kh-i", String(line));
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [mode, reduceMotion, children]);
if (reduceMotion) {
return (
<Tag className={`text-balance ${className}`} style={style} {...props}>
{children}
</Tag>
);
}
const lines = splitText(children);
const fragmentClass = blur ? `${FRAGMENT} ${FRAGMENT_BLUR}` : FRAGMENT;
return (
<Tag
ref={rootRef}
data-state={revealed ? "in" : "out"}
className={`group/kh text-balance ${className}`}
style={{
["--kh-duration" as string]: `${duration}ms`,
["--kh-stagger" as string]: `${stagger}ms`,
["--kh-delay" as string]: `${delay}ms`,
["--kh-ease" as string]: EASINGS[easing],
...style,
}}
{...props}
>
{/* Screen readers get the sentence once, uninterrupted. */}
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{lines.map((words, lineIndex) => (
<React.Fragment key={lineIndex}>
{lineIndex > 0 && <br />}
{words.map((word, i) => (
<React.Fragment key={word.index}>
{i > 0 && " "}
{/* Each word is a mask: fragments travel up from below its baseline. Padding keeps descenders visible. */}
<span
data-kh-mask=""
className="-mb-[0.12em] inline-block overflow-hidden whitespace-nowrap pb-[0.12em] align-bottom"
style={mode === "word" ? { ["--kh-i" as string]: word.index } : undefined}
>
{mode === "char" ? (
Array.from(word.text).map((char, c) => (
<span
key={c}
className={fragmentClass}
style={{ ["--kh-i" as string]: word.charStart + c }}
>
{char}
</span>
))
) : (
<span className={fragmentClass}>{word.text}</span>
)}
</span>
</React.Fragment>
))}
</React.Fragment>
))}
</span>
</Tag>
);
}