Stagger Chars

PreviousNext

An animated text component that creates a smooth, staggered character animation effect when hovered.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/stagger-chars.tsx
"use client";
import * as React from "react";
import {
  AnimatePresence,
  motion,
  type Variants,
  useReducedMotion,
} from "framer-motion";
import { cn } from "@/lib/utils";

interface StaggerCharsProps {
  text: string;
  hoverText?: string;
  delay?: number;
  duration?: number;
  className?: string;
  hoverClassName?: string;
  direction?: "up" | "down" | "alternate";
  easing?: number[];
  disabled?: boolean;
  onAnimationStart?: () => void;
  onAnimationComplete?: () => void;
}

const useProcessedChars = (text: string, hoverText?: string) =>
  React.useMemo(() => {
    const base = text.split("");
    const hover = (hoverText ?? text).split("");
    const max = Math.max(base.length, hover.length);

    return {
      safeBase: Array.from({ length: max }, (_, i) => base[i] ?? " "),
      safeHover: Array.from({ length: max }, (_, i) => hover[i] ?? " "),
    };
  }, [text, hoverText]);

const useIsTouchDevice = () => {
  const [isTouch, setIsTouch] = React.useState(false);

  React.useEffect(() => {
    const check = () =>
      setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0);

    check();
    window.addEventListener("resize", check);
    return () => window.removeEventListener("resize", check);
  }, []);

  return isTouch;
};

const getInitialY = (
  direction: StaggerCharsProps["direction"],
  isEven: boolean
) => {
  switch (direction) {
    case "up":
      return "0%";
    case "down":
      return "-50%";
    case "alternate":
    default:
      return isEven ? "-50%" : "0%";
  }
};

const getTargetY = (
  direction: StaggerCharsProps["direction"],
  isEven: boolean
) => {
  switch (direction) {
    case "up":
      return "-50%";
    case "down":
      return "0%";
    case "alternate":
    default:
      return isEven ? "0%" : "-50%";
  }
};

const StaggerChars = React.memo<StaggerCharsProps>(
  ({
    text,
    hoverText,
    hoverClassName,
    delay = 0.05,
    duration = 1,
    className,
    direction = "alternate",
    easing = [0.22, 1, 0.36, 1],
    disabled = false,
    onAnimationStart,
    onAnimationComplete,
  }) => {
    const { safeBase, safeHover } = useProcessedChars(text, hoverText);
    const prefersReducedMotion = useReducedMotion();
    const isTouchDevice = useIsTouchDevice();

    const [isHovered, setIsHovered] = React.useState(false);
    const [isAutoAnimating, setIsAutoAnimating] = React.useState(false);
    const intervalRef = React.useRef<NodeJS.Timeout>();

    React.useEffect(() => {
      if (!isTouchDevice || disabled) return;
      const timeout = setTimeout(() => {
        setIsAutoAnimating(true);
        onAnimationStart?.();
        intervalRef.current = setInterval(
          () => setIsAutoAnimating((prev) => !prev),
          2000
        );
      }, 1000);

      return () => {
        clearTimeout(timeout);
        if (intervalRef.current) clearInterval(intervalRef.current);
      };
    }, [isTouchDevice, disabled, onAnimationStart]);

    const containerVariants: Variants = {
      initial: {},
      hover: {
        transition: {
          staggerChildren: prefersReducedMotion ? 0 : delay,
        },
      },
      exit: {},
    };

    const stackVariants: Variants = {
      initial: ({ isEven }: { index: number; isEven: boolean }) =>
        prefersReducedMotion
          ? { y: "0%" }
          : { y: getInitialY(direction, isEven) },
      hover: ({ index, isEven }: { index: number; isEven: boolean }) =>
        prefersReducedMotion
          ? { y: "0%" }
          : {
              y: getTargetY(direction, isEven),
              transition: {
                duration,
                delay: index * delay,
                ease: easing,
              },
            },
      exit: ({ isEven }: { index: number; isEven: boolean }) =>
        prefersReducedMotion
          ? { y: "0%" }
          : { y: getInitialY(direction, isEven) },
    };

    const handleHoverStart = () => {
      if (disabled || isTouchDevice) return;
      setIsHovered(true);
      onAnimationStart?.();
    };

    const handleHoverEnd = () => {
      if (disabled || isTouchDevice) return;
      setIsHovered(false);
      onAnimationComplete?.();
    };

    return (
      <AnimatePresence mode="wait">
        <motion.div
          className={cn(
            "relative h-fit uppercase text-black dark:text-white leading-none",
            "select-none transform-gpu will-change-transform",
            !disabled && "cursor-pointer",
            className
          )}
          variants={containerVariants}
          initial="initial"
          exit="exit"
          whileHover={disabled || isTouchDevice ? undefined : "hover"}
          animate={
            isTouchDevice && !disabled
              ? isAutoAnimating
                ? "hover"
                : "initial"
              : undefined
          }
          onHoverStart={handleHoverStart}
          onHoverEnd={handleHoverEnd}
          style={{ perspective: 1000 }}
          role="text"
          aria-label={text}
          aria-live={isHovered ? "polite" : undefined}
        >
          {safeBase.map((char, index) => {
            const nextChar = safeHover[index];
            const isSpace = char === " " && nextChar === " ";
            const isEven = index % 2 === 0;

            return (
              <span
                key={index}
                className="inline-block h-[1em] align-baseline overflow-hidden transform-gpu will-change-transform relative"
                style={{ lineHeight: 1 }}
                aria-hidden="true"
              >
                <motion.span
                  className="block relative"
                  variants={stackVariants}
                  custom={{ index, isEven }}
                  style={{
                    backfaceVisibility: "hidden",
                    transform: "translateZ(0)",
                    lineHeight: 1,
                  }}
                >
                  {isEven && (
                    <span className={cn("block h-[1em] leading-none", hoverClassName)} style={{ lineHeight: 1 }}>
                      {isSpace ? "\u00A0" : nextChar}
                    </span>
                  )}
                  <span className="block h-[1em] leading-none" style={{ lineHeight: 1 }}>
                    {isSpace ? "\u00A0" : char}
                  </span>
                  {!isEven && (
                    <span className={cn("block h-[1em] leading-none", hoverClassName)} style={{ lineHeight: 1 }}>
                      {isSpace ? "\u00A0" : nextChar}
                    </span>
                  )}
                </motion.span>
              </span>
            );
          })}
        </motion.div>
      </AnimatePresence>
    );
  }
);

StaggerChars.displayName = "StaggerChars";
export type { StaggerCharsProps };
export default StaggerChars;

Installation

npx shadcn@latest add @scrollxui/stagger-chars

Usage

import { StaggerChars } from "@/components/stagger-chars"
<StaggerChars />