MacTech / Design System
Cyan

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>
  );
}