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 props2400
6
Installation
Copies the component source and its CSS Module into your project.
npx motionfoundry add text-morphWrites 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
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}