CoreAdvanced Text Animationsmotion/react

TextMorph

Seamless word and phrase morphing primitive that coordinates layout width transitions, baseline alignment, autoplay cycling, and manual step controls.

Preview

Engineered forzero-jank layouts
1/4

Customize

5 props
2400
6

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add text-morph

Writes components/ui/text-morph.tsx and components/styles/text-morph.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { TextMorph } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <TextMorph />6  );7}

Props

PropTypeDefault
prefix

Static leading text aligned to the morphing baseline.

string'Engineered for'
phrases

Array of words or short phrases to morph between.

string[]['zero-jank layouts', ...]
interval

Milliseconds between automatic transitions.

number2400
autoplay

Whether to cycle automatically.

booleantrue
manualControls

Render interactive Prev/Next phrase controls.

booleantrue
blurTransition

Optical blur radius during phrase crossfade.

number6

Accessibility

Announces the active phrase via aria-live='polite' and exposes keyboard-focusable manual Prev/Next controls when manualControls is enabled.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/text-morph.tsx
1"use client";2 3import * as React from "react";4import { motion, AnimatePresence, useReducedMotion } from "motion/react";5import styles from "../styles/text-morph.module.css";6 7export interface TextMorphProps {8  /** Leading static text */9  prefix?: string;10  /** Array of phrases to morph through */11  phrases?: string[];12  /** Cycle interval in milliseconds */13  interval?: number;14  /** Enable automatic cycling */15  autoplay?: boolean;16  /** Render interactive prev/next buttons */17  manualControls?: boolean;18  /** Optical blur radius during transition */19  blurTransition?: number;20  /** Optional className */21  className?: string;22}23 24const DEFAULT_PHRASES = [25  "zero-jank layouts",26  "120Hz spring physics",27  "Next.js App Router",28  "accessible motion",29];30 31export function TextMorph({32  prefix = "Engineered for",33  phrases = DEFAULT_PHRASES,34  interval = 2400,35  autoplay = true,36  manualControls = true,37  blurTransition = 6,38  className = "",39}: TextMorphProps) {40  const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES;41  const [index, setIndex] = React.useState(0);42  const prefersReducedMotion = useReducedMotion();43 44  React.useEffect(() => {45    if (!autoplay || prefersReducedMotion || safePhrases.length <= 1) return;46    const id = window.setInterval(() => {47      setIndex((prev) => (prev + 1) % safePhrases.length);48    }, interval);49    return () => window.clearInterval(id);50  }, [autoplay, interval, safePhrases.length, prefersReducedMotion]);51 52  const currentPhrase = safePhrases[index % safePhrases.length];53 54  return (55    <div className={`${styles.wrapper} ${className}`.trim()}>56      <div className={styles.sentenceRow} aria-live="polite">57        {prefix && <span className={styles.prefix}>{prefix}</span>}58        <motion.span layout className={styles.morphPill}>59          <AnimatePresence mode="popLayout" initial={false}>60            <motion.span61              key={currentPhrase}62              initial={63                prefersReducedMotion64                  ? { opacity: 1 }65                  : { opacity: 0, y: 14, filter: `blur(${blurTransition}px)` }66              }67              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}68              exit={69                prefersReducedMotion70                  ? { opacity: 0 }71                  : { opacity: 0, y: -14, filter: `blur(${blurTransition}px)` }72              }73              transition={{ type: "spring", stiffness: 280, damping: 24 }}74              className={styles.phrase}75            >76              {currentPhrase}77            </motion.span>78          </AnimatePresence>79        </motion.span>80      </div>81      {manualControls && (82        <div className={styles.controls}>83          <button84            type="button"85            onClick={() =>86              setIndex((prev) => (prev - 1 + safePhrases.length) % safePhrases.length)87            }88            aria-label="Previous phrase"89            className={styles.stepBtn}90          >91            ← Prev92          </button>93          <span className={styles.counter}>94            {(index % safePhrases.length) + 1}/{safePhrases.length}95          </span>96          <button97            type="button"98            onClick={() => setIndex((prev) => (prev + 1) % safePhrases.length)}99            aria-label="Next phrase"100            className={styles.stepBtn}101          >102            Next →103          </button>104        </div>105      )}106    </div>107  );108}