registry / bespoke
kinetic-text
Character-staggered hero typography with optional Instrument Serif italic em-phrase.
preview · live in active mood
source · components/mactech/kinetic-text.tsx
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
export interface KineticTextProps {
text: string;
/** Substring of `text` to italicize and apply the accent-gradient
* treatment to. If the substring isn't found, it falls back to
* appending the emphasis at the end (the old behavior). */
emphasis?: string;
/** Deprecated. Kept for backwards compatibility — the splice point
* is now derived from where `emphasis` appears inside `text`. */
emphasisAfter?: string;
className?: string;
style?: CSSProperties;
/** Stagger between each character, in seconds. */
stagger?: number;
}
const containerVariants = {
hidden: {},
visible: (stagger: number) => ({
transition: { staggerChildren: stagger },
}),
};
const charVariants = {
hidden: { opacity: 0, y: 18, filter: "blur(8px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] },
},
};
const splitChars = (s: string) => Array.from(s);
const emStyle: CSSProperties = {
fontStyle: "italic",
backgroundImage:
"linear-gradient(135deg, var(--mt-accent), var(--mt-accent-2))",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
};
function splitText(text: string, emphasis?: string) {
if (!emphasis) return { before: text, emph: "", after: "" };
const idx = text.indexOf(emphasis);
if (idx === -1) {
// emphasis isn't a substring — fall back to appending it. Keeps
// older call sites working without producing a duplicate render.
return { before: text, emph: emphasis, after: "" };
}
return {
before: text.slice(0, idx),
emph: emphasis,
after: text.slice(idx + emphasis.length),
};
}
export function KineticText({
text,
emphasis,
className,
style,
stagger = 0.018,
}: KineticTextProps) {
const reduced = useReducedMotion();
const { before, emph, after } = splitText(text, emphasis);
const renderChars = (slice: string, baseIndex: number) =>
splitChars(slice).map((ch, i) => (
<motion.span
key={`${baseIndex}-${i}-${ch}`}
variants={charVariants}
className="inline-block"
style={{ whiteSpace: ch === " " ? "pre" : undefined }}
>
{ch}
</motion.span>
));
if (reduced) {
return (
<h1 className={className} style={style}>
{before}
{emph ? (
<em className="mt-kinetic-em font-mt-serif" style={emStyle}>
{emph}
</em>
) : null}
{after}
</h1>
);
}
return (
<motion.h1
className={className}
style={style}
initial="hidden"
animate="visible"
variants={containerVariants}
custom={stagger}
>
{renderChars(before, 0)}
{emph ? (
<motion.em
className="mt-kinetic-em font-mt-serif"
style={emStyle}
variants={charVariants}
>
{emph}
</motion.em>
) : null}
{renderChars(after, 2)}
</motion.h1>
);
}