Dirck Mulder
Text Animations||3 min read

Build a Rotating Text Component in React

Cycle through a list of words with a smooth rotation or slide animation, ideal for dynamic hero taglines in React.

"I design things." is fine. "I design websites / apps / experiences" cycling one after another is far more interesting. Rotating Text is the simplest way to fit multiple ideas into a single fixed space without a carousel.

The final result

RotatingText cycles through an array of strings at a configurable interval. Each string animates in from below while the previous one exits upward. Characters can be split and staggered individually. The component exposes an imperative API via ref so you can control it programmatically.

Setting up

This component uses Motion's AnimatePresence to handle simultaneous enter and exit animations.

tsx
import { motion, AnimatePresence } from 'motion/react';
import { forwardRef, useImperativeHandle, useCallback, useEffect, useState, useMemo } from 'react';

Building the component

The component is wrapped in forwardRef to expose the imperative API. The handle gives you next, previous, jumpTo, and reset methods.

tsx
useImperativeHandle(ref, () => ({ next, previous, jumpTo, reset }), [next, previous, jumpTo, reset]);

The elements array is built from the current text string. In splitBy="characters" mode, we use Intl.Segmenter to split the string correctly, which handles emoji and multi-byte characters that Array.from(text) would mangle.

tsx
const splitIntoCharacters = (text: string): string[] => {
  if (typeof Intl !== 'undefined' && Intl.Segmenter) {
    const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
    return Array.from(segmenter.segment(text), segment => segment.segment);
  }
  return Array.from(text);
};

The stagger delay function supports several stagger origins: from the first character, from the last, from the center, or from a random position.

tsx
const getStaggerDelay = (index: number, totalChars: number): number => {
  if (staggerFrom === 'first') return index * staggerDuration;
  if (staggerFrom === 'last') return (totalChars - 1 - index) * staggerDuration;
  if (staggerFrom === 'center') {
    const center = Math.floor(totalChars / 2);
    return Math.abs(center - index) * staggerDuration;
  }
  if (staggerFrom === 'random') {
    const randomIndex = Math.floor(Math.random() * totalChars);
    return Math.abs(randomIndex - index) * staggerDuration;
  }
  return Math.abs((staggerFrom as number) - index) * staggerDuration;
};

AnimatePresence with mode="wait" ensures the exit animation completes before the new string starts entering.

tsx
<AnimatePresence mode={animatePresenceMode} initial={animatePresenceInitial}>
  <motion.span key={currentTextIndex} layout aria-hidden='true'>
    {elements.map((wordObj, wordIndex, array) => {
      const previousCharsCount = array
        .slice(0, wordIndex)
        .reduce((sum, word) => sum + word.characters.length, 0);
      return (
        <span key={wordIndex} className='inline-flex'>
          {wordObj.characters.map((char, charIndex) => (
            <motion.span
              key={charIndex}
              initial={initial}
              animate={animate}
              exit={exit}
              transition={{
                ...transition,
                delay: getStaggerDelay(previousCharsCount + charIndex, totalChars),
              }}
              className='inline-block'
            >
              {char}
            </motion.span>
          ))}
        </span>
      );
    })}
  </motion.span>
</AnimatePresence>

How to use it

tsx
<RotatingText
  texts={['designers', 'developers', 'founders']}
  rotationInterval={2000}
  staggerDuration={0.025}
  staggerFrom="first"
  initial={{ y: '100%', opacity: 0 }}
  animate={{ y: 0, opacity: 1 }}
  exit={{ y: '-120%', opacity: 0 }}
/>

Access the imperative API by passing a ref and calling ref.current.next() from a button or any other trigger.

Key takeaways

  • Using Intl.Segmenter for character splitting is the correct approach in 2025. It handles emoji, combining characters, and other multi-codepoint graphemes that simple string iteration would split incorrectly.
  • Keeping a screen-reader-only <span> with the current text alongside the animated visual output ensures the rotating text is accessible to assistive technology.
  • The animatePresenceMode="wait" option is what prevents the enter and exit animations from overlapping, which would look like two texts fighting for the same space.