Text Effects

Text Loop

Rotates one word inside a sentence with a direction-aware rise enter and half-duration exit.

Install

npx shadcn@latest add @paragon/text-loop

text-loop.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";

/** Mirrors var(--ease-out) — motion transition arrays can't read CSS custom properties. */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

const ENTER_DURATION = 0.3;
/** Exits run at half the enter so the old word never fights for attention. */
const EXIT_DURATION = ENTER_DURATION / 2;
const RISE_DISTANCE = 12;

export interface TextLoopProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The words to rotate through, in order. */
  children: string[];
  /** Seconds each word holds before rotating to the next. */
  interval?: number;
  /**
   * Travel direction. "up" (default) enters from below and exits above;
   * "down" reverses both so the stack reads as scrolling the other way.
   */
  direction?: "up" | "down";
  /** Swaps words instantly, without motion. */
  static?: boolean;
}

/**
 * Rotates one word inside a sentence using the house enter/exit language:
 * the incoming word rises in (opacity, translateY, blur, ease-out) while the
 * outgoing word exits the opposite way at half duration with an exit ease.
 *
 * Layout stability comes from an inline-grid: every word is rendered as an
 * invisible sizing span stacked in the same grid cell, so the container is
 * always as wide as the longest word and the surrounding sentence never
 * jumps — no measurement, no width animation, correct on first paint.
 *
 * The loop pauses offscreen (useInView) and skips ticks in hidden tabs.
 */
export function TextLoop({
  children,
  interval = 2.5,
  direction = "up",
  static: isStatic = false,
  className,
  ...props
}: TextLoopProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref);
  const reducedMotion = useReducedMotion();
  const [index, setIndex] = React.useState(0);
  const count = children.length;

  React.useEffect(() => {
    if (count < 2 || !inView) return;
    const id = setInterval(() => {
      // Skip ticks while the tab is hidden so the loop resumes in place.
      if (document.hidden) return;
      setIndex((i) => (i + 1) % count);
    }, interval * 1000);
    return () => clearInterval(id);
  }, [count, interval, inView]);

  if (count === 0) return null;

  const word = children[index % count];
  const enterY = direction === "up" ? RISE_DISTANCE : -RISE_DISTANCE;
  const exitY = -enterY;

  return (
    <span
      ref={ref}
      className={cn(
        "relative inline-grid items-baseline whitespace-nowrap",
        className,
      )}
      {...props}
    >
      {/* Stable text for assistive tech — the animated layer briefly holds
          both the old and new word during the swap. */}
      <span className="sr-only">{word}</span>
      {/* Invisible sizing spans: the widest word sets the cell width, so the
          sentence around the loop never reflows. */}
      {children.map((w, i) => (
        <span
          key={`${w}-${i}`}
          aria-hidden
          className="invisible [grid-area:1/1] whitespace-nowrap"
        >
          {w}
        </span>
      ))}
      {isStatic ? (
        <span aria-hidden className="[grid-area:1/1]">
          {word}
        </span>
      ) : (
        <AnimatePresence initial={false}>
          <motion.span
            key={index}
            aria-hidden
            className="[grid-area:1/1]"
            initial={{
              opacity: 0,
              y: reducedMotion ? 0 : enterY,
              filter: reducedMotion ? "blur(0px)" : "blur(4px)",
            }}
            animate={{
              opacity: 1,
              y: 0,
              filter: "blur(0px)",
              transition: { duration: ENTER_DURATION, ease: EASE_OUT },
            }}
            exit={{
              opacity: 0,
              y: reducedMotion ? 0 : exitY,
              filter: reducedMotion ? "blur(0px)" : "blur(4px)",
              transition: { duration: EXIT_DURATION, ease: EASE_EXIT },
            }}
          >
            {word}
          </motion.span>
        </AnimatePresence>
      )}
    </span>
  );
}