Fazekit

Code

"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";

type Tag = "h1" | "h2" | "h3" | "h4" | "p" | "span" | "div";

export interface SplitTextRevealProps {
  text: string;
  /** Split into words or individual characters. */
  by?: "word" | "char";
  as?: Tag;
  /** Seconds between each piece. */
  stagger?: number;
  /** Seconds before the first piece starts. */
  delay?: number;
  /** Seconds each piece takes. */
  duration?: number;
  /** Starting vertical offset, e.g. "0.6em" or 24. */
  offset?: string | number;
  /** Starting blur in px (0 to disable). */
  blur?: number;
  /** Reveal only the first time it enters view. */
  once?: boolean;
  /** How much of the element must be visible (0–1). */
  amount?: number;
  /** Scroll container to observe against (defaults to the viewport). */
  root?: React.RefObject<Element | null>;
  className?: string;
  /** Class applied to each word / character span (e.g. a gradient). */
  pieceClassName?: string;
}

export function SplitTextReveal({
  text,
  by = "word",
  as = "h2",
  stagger,
  delay = 0,
  duration = 0.6,
  offset = "0.5em",
  blur = 10,
  once = true,
  amount = 0.4,
  root,
  className,
  pieceClassName,
}: SplitTextRevealProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { once, amount, root });
  const step = stagger ?? (by === "char" ? 0.025 : 0.07);

  const piece: Variants = {
    hidden: reduce ? { opacity: 0 } : { opacity: 0, y: offset, filter: `blur(${blur}px)` },
    show: (i: number) => ({
      opacity: 1,
      y: 0,
      filter: "blur(0px)",
      transition: { delay: delay + i * step, duration: reduce ? 0.2 : duration, ease: [0.22, 1, 0.36, 1] },
    }),
  };

  const words = text.split(/(\s+)/);
  let counter = 0;

  const Comp = motion[as] as typeof motion.div;

  return (
    <Comp
      ref={ref as React.RefObject<HTMLDivElement>}
      initial="hidden"
      animate={inView ? "show" : "hidden"}
      className={cn(className)}
    >
      <span className="sr-only">{text}</span>
      {words.map((w, wi) => {
        if (/^\s+$/.test(w)) return <span key={wi} aria-hidden> </span>;
        if (by === "word") {
          const i = counter++;
          return (
            <motion.span key={wi} aria-hidden custom={i} variants={piece} className={cn("inline-block will-change-transform", pieceClassName)}>
              {w}
            </motion.span>
          );
        }
        // Keep characters of one word together so lines never break mid-word.
        return (
          <span key={wi} aria-hidden className="inline-block whitespace-nowrap">
            {Array.from(w).map((c, ci) => {
              const i = counter++;
              return (
                <motion.span key={ci} custom={i} variants={piece} className={cn("inline-block will-change-transform", pieceClassName)}>
                  {c}
                </motion.span>
              );
            })}
          </span>
        );
      })}
    </Comp>
  );
}

More in Text Effects

View all →