DesignPass.dev

SplitText

// preview

Text
Split by
charswords
Stagger from
startcenterendrandom
Stagger (ms)
Duration (ms)
Rise distance (px)
Tilt (deg)

// source

TSJS
TailwindCSS
/*!
 * SplitText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
 * Docs & live playground: https://designpass.dev/components/split-text
 * MIT licensed, keep this notice in copies and adaptations.
 */
"use client";

import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";

export interface SplitTextProps {
  text: string;
  /** Animate per character or per word. */
  splitBy?: "chars" | "words";
  /** Delay (ms) before the reveal starts once in view. */
  delay?: number;
  /** Gap (ms) between consecutive units. */
  stagger?: number;
  /** Where the stagger wave starts. */
  staggerFrom?: "start" | "center" | "end" | "random";
  /** Duration (ms) of each unit's reveal. */
  duration?: number;
  /** Initial vertical offset (px). */
  yOffset?: number;
  /** Initial tilt (deg), settles to 0 as the unit lands. */
  tilt?: number;
  /** WAAPI easing; the default overshoots slightly for a springy landing. */
  easing?: string;
  /** IntersectionObserver threshold that triggers the reveal. */
  threshold?: number;
  rootMargin?: string;
  /** Play only the first time it enters the viewport. */
  once?: boolean;
  onComplete?: () => void;
  className?: string;
  style?: CSSProperties;
}

/** Grapheme-safe character split, keeps emoji and accents intact. */
function splitGraphemes(word: string): string[] {
  if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
    return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)].map(
      (segment) => segment.segment,
    );
  }
  return [...word];
}

/**
 * Splits text into words or characters and reveals them in a staggered
 * wave when scrolled into view. Powered by the Web Animations API: the
 * server-rendered text stays intact for SEO and no-JS readers, and each
 * unit animates only transform + opacity. Zero dependencies.
 */
export default function SplitText({
  text,
  splitBy = "chars",
  delay = 0,
  stagger = 34,
  staggerFrom = "start",
  duration = 700,
  yOffset = 26,
  tilt = 5,
  easing = "cubic-bezier(0.22, 1.35, 0.36, 1)",
  threshold = 0.15,
  rootMargin = "0px",
  once = true,
  onComplete,
  className = "",
  style,
}: SplitTextProps) {
  const containerRef = useRef<HTMLSpanElement>(null);
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const units = Array.from(container.querySelectorAll<HTMLElement>("[data-split-unit]"));
    if (units.length === 0) return;

    const count = units.length;
    const order = units.map((_, i) => {
      if (staggerFrom === "center") return Math.abs(i - (count - 1) / 2);
      if (staggerFrom === "end") return count - 1 - i;
      if (staggerFrom === "random") return Math.random() * count;
      return i;
    });

    // Paused animations with backwards fill hide the units up front, so the
    // SSR markup ships visible and only goes hidden once JS is ready.
    const animations = units.map((unit, i) => {
      const animation = unit.animate(
        [
          {
            opacity: 0,
            transform: `translate3d(0, ${yOffset}px, 0) rotate(${tilt}deg)`,
          },
          { opacity: 1, transform: "translate3d(0, 0, 0) rotate(0deg)" },
        ],
        {
          duration,
          delay: delay + order[i] * stagger,
          easing,
          fill: "both",
        },
      );
      animation.pause();
      return animation;
    });

    let played = false;
    const play = () => {
      for (const animation of animations) {
        animation.currentTime = 0;
        animation.play();
      }
      if (!played) {
        played = true;
        Promise.all(animations.map((animation) => animation.finished))
          .then(() => onCompleteRef.current?.())
          .catch(() => {});
      }
    };

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          play();
          if (once) observer.disconnect();
        }
      },
      { threshold, rootMargin },
    );
    observer.observe(container);

    return () => {
      observer.disconnect();
      for (const animation of animations) animation.cancel();
    };
  }, [
    text,
    splitBy,
    delay,
    stagger,
    staggerFrom,
    duration,
    yOffset,
    tilt,
    easing,
    threshold,
    rootMargin,
    once,
  ]);

  return (
    <span
      ref={containerRef}
      aria-label={text}
      role="text"
      className={`inline-block ${className}`}
      style={style}
    >
      {words.map((word, wordIndex) => (
        <React.Fragment key={`${word}-${wordIndex}`}>
          {splitBy === "words" ? (
            <span data-split-unit aria-hidden="true" className="inline-block will-change-transform">
              {word}
            </span>
          ) : (
            // Chars are grouped per word in a nowrap box so lines still
            // break between words, never through them.
            <span aria-hidden="true" className="inline-block whitespace-nowrap">
              {splitGraphemes(word).map((char, charIndex) => (
                <span
                  key={charIndex}
                  data-split-unit
                  className="inline-block will-change-transform"
                >
                  {char}
                </span>
              ))}
            </span>
          )}
          {wordIndex < words.length - 1 ? " " : null}
        </React.Fragment>
      ))}
    </span>
  );
}

// install

Install the SplitText component from DesignPass into this project by running:

npx shadcn@latest add "https://designpass.dev/r/SplitText-TS-TW.json"

If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.

Need the license details? Read the component license.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.