Skip to content

Letter Cascade

Letters drop and swap in sequence.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/letter-cascade.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/letter-cascade/letter-cascade.tsx
    "use client";
    
    import { useState, type ComponentProps } from "react";
    import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, spring } from "@/lib/motion";
    
    export interface LetterCascadeProps
      extends Omit<ComponentProps<"span">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Current text. When it changes, letters that differ drop out and their replacements drop in, one after another. */
      text: string;
      /** Seconds between letters. @default 0.035 */
      stagger?: number;
      /** Which way letters fall. @default "down" */
      direction?: "down" | "up";
      /** Cascade the letters in on first render too. @default true */
      animateOnMount?: boolean;
    }
    
    function graphemes(value: string) {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment);
      }
      return Array.from(value);
    }
    
    /** Changed positions get increasing delays in order; unchanged letters don't move. */
    function cascadeDelays(from: string[], to: string[], stagger: number) {
      const delays: number[] = [];
      let k = 0;
      for (let i = 0; i < Math.max(from.length, to.length); i++) {
        delays.push(from[i] === to[i] ? 0 : k++ * stagger);
      }
      return delays;
    }
    
    export function LetterCascade({
      text,
      stagger = 0.035,
      direction = "down",
      animateOnMount = true,
      className,
      ...props
    }: LetterCascadeProps) {
      const reduceMotion = useReducedMotion();
      const chars = graphemes(text);
      const [previous, setPrevious] = useState(text);
      const [delays, setDelays] = useState(() => chars.map((_, i) => i * stagger));
      const [generation, setGeneration] = useState(0);
      if (previous !== text) {
        setPrevious(text);
        setDelays(cascadeDelays(graphemes(previous), chars, stagger));
        setGeneration((g) => g + 1);
      }
    
      const fall = direction === "down" ? 1 : -1;
      const tilt = (i: number) => (i % 2 ? 10 : -10);
    
      const letter = (i: number): Variants => ({
        enter: reduceMotion ? { opacity: 0 } : { opacity: 0, y: `${-0.75 * fall}em`, rotate: tilt(i), filter: "blur(2px)" },
        center: (delay: number) => ({
          opacity: 1,
          y: 0,
          rotate: 0,
          filter: "blur(0px)",
          transition: reduceMotion ? { duration: duration.base, delay } : { ...spring.smooth, delay: delay + 0.05 },
        }),
        exit: (delay: number) => ({
          opacity: 0,
          ...(reduceMotion ? {} : { y: `${0.75 * fall}em`, rotate: -tilt(i), filter: "blur(2px)" }),
          transition: { duration: duration.base, ease: [0.55, 0, 1, 0.45], delay },
        }),
      });
    
      return (
        <span className={cn("inline-block whitespace-nowrap", className)} {...props}>
          <span className="sr-only select-none">{text}</span>
          {/* Inline-level letters (not flex items), so copied text comes out as one word. */}
          <span aria-hidden="true">
            <AnimatePresence mode="popLayout" initial={false} custom={delays}>
              {chars.map((char, i) => (
                <motion.span
                  key={i}
                  layout={reduceMotion ? false : "position"}
                  transition={spring.smooth}
                  className="relative inline-block"
                  // A removed slot (the text got shorter) drops its letter like any other change.
                  variants={{
                    exit: (all: number[]) => ({
                      opacity: 0,
                      ...(reduceMotion ? {} : { y: `${0.75 * fall}em`, rotate: -tilt(i) }),
                      transition: { duration: duration.base, ease: [0.55, 0, 1, 0.45], delay: all[i] ?? 0 },
                    }),
                  }}
                  exit="exit"
                >
                  <AnimatePresence mode="popLayout" initial={animateOnMount || generation > 0} custom={delays[i] ?? 0}>
                    <motion.span
                      key={char}
                      custom={delays[i] ?? 0}
                      variants={letter(i)}
                      initial="enter"
                      animate="center"
                      exit="exit"
                      inherit={false}
                      className="inline-block whitespace-pre"
                    >
                      {char === " " ? " " : char}
                    </motion.span>
                  </AnimatePresence>
                </motion.span>
              ))}
            </AnimatePresence>
          </span>
        </span>
      );
    }

Usage

import { LetterCascade } from "@/components/ui/letter-cascade/letter-cascade";

export function FollowButton({ following }: { following: boolean }) {
  return (
    <button aria-pressed={following}>
      <LetterCascade text={following ? "Following" : "Follow"} animateOnMount={false} />
    </button>
  );
}

API reference

PropTypeDefaultDescription
text*string–Current text. When it changes, letters that differ drop out and their replacements drop in, one after another.
staggernumber0.035Seconds between letters.
direction"down" | "up""down"Which way letters fall.
animateOnMountbooleantrueCascade the letters in on first render too.

Also accepts every native <span> attribute, including ref and className.

  • Only positions whose letter changed animate, in order, so "Follow" → "Following" keeps "Follow" still and drops in "ing".
  • Letters shift with layout animation when neighbours change width. Meant for a single line of short text.

Accessibility

  • Screen readers get the current text from a visually hidden copy; the falling letters are aria-hidden.
  • Announce changes the way you normally would, e.g. aria-pressed on a toggle, or an aria-live region for status text.
  • With reduced motion enabled, letters crossfade without falling or tilting.