Back

Text switcher

A reusable component that creates an engaging animation effect by smoothly transitioning between a list of phrases. The animation, driven by a moving dot, makes it ideal for dynamically completing a sentence or tagline.

Current Theme:
As someone who styles divs and
solves backend 
nightmares, I write
code that 
c
o
m
p
i
l
e
s
.

API usage

components/text-switcher.demo.tsx
import { TextSwitcher } from "./text-switcher";

export function TextSwitcherDemo() {
  return (
    <div className="text-sm sm:text-base md:text-xl lg:text-2xl">
      As someone who styles divs and <br className="md:hidden" /> solves
      backend&nbsp;
      <br className="max-md:hidden" />
      nightmares, I write <br className="md:hidden" />
      code that&nbsp;
      <TextSwitcher
        phrases={["compiles", "ships", "breaks", "runs anyway"]}
        className="font-medium"
      />
    </div>
  );
}

Installation

CLI (recommended)

1
Run the command below to add the component to your project.
It will also generate the required base stylesheet if one doesn't already exist and guide you through setting up the import alias @/components/... if it isn't already configured.
pnpm dlx shadcn@latest add https://100xui.com/components/text-switcher.json

Manual

1
Install the following dependencies.
pnpm add clsx motion tailwind-merge
2
Copy and paste the following code into your project.
100xui
components/text-switcher.tsx
"use client";

import React from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useTransform,
  useVelocity,
} from "motion/react";

import { cn } from "@/lib/utils";

export interface TextSwitcherProps {
  phrases: string[];
  readTimeInSec?: number;
  animationDurationInSec?: number;
  className?: string;
  style?: React.CSSProperties;
}

const letterVariants = {
  initial: { scaleX: 0, opacity: 0 },
  animate: { scaleX: 1, opacity: 1 },
  exit: { scaleX: 0, opacity: 0 },
};

export function TextSwitcher({
  phrases,
  animationDurationInSec = 0.4,
  readTimeInSec = 2,
  className,
  style,
}: TextSwitcherProps) {
  const [currentPhrase, setCurrentPhrase] = React.useState(0);

  const dotLeft = useMotionValue("100%");
  const dotLeftAsFloat = useTransform(dotLeft, (latest) => parseFloat(latest));
  const dotVelocity = useVelocity(dotLeftAsFloat);

  const dotScaleX = useTransform(dotVelocity, [-120, 0, 120], [3, 1, 3]);
  const dotColor = useTransform(dotVelocity, (latest) => {
    return Math.round(latest) !== 0 ? "var(--destructive)" : "";
  });

  React.useEffect(() => {
    const totalCycleTimeInMs =
      (readTimeInSec + 2 * animationDurationInSec) * 1000;
    const controlInterval = setInterval(() => {
      setCurrentPhrase((prevIndex) => (prevIndex + 1) % phrases.length);
    }, totalCycleTimeInMs);

    return () => clearInterval(controlInterval);
  }, [phrases.length, readTimeInSec, animationDurationInSec]);

  const containerVariants = {
    animate: {
      transition: {
        staggerChildren: animationDurationInSec / phrases[currentPhrase].length,
      },
    },
    exit: {
      transition: {
        staggerChildren: animationDurationInSec / phrases[currentPhrase].length,
        staggerDirection: -1,
      },
    },
  };

  return (
    <div
      className={cn("inline-block", className, "!relative")}
      style={{ ...style }}
    >
      <AnimatePresence initial={false} mode="wait">
        <motion.div
          key={currentPhrase}
          className="flex flex-nowrap whitespace-pre"
          variants={containerVariants}
          initial="initial"
          animate="animate"
          exit="exit"
        >
          {phrases[currentPhrase].split("").map((letter, i) => (
            <motion.div
              key={`phrases[${currentPhrase}][${i}]`}
              className="origin-left"
              variants={letterVariants}
              transition={{
                duration:
                  animationDurationInSec / phrases[currentPhrase].length,
              }}
            >
              {letter}
            </motion.div>
          ))}
        </motion.div>
      </AnimatePresence>
      <AnimatePresence initial={false} mode="wait">
        <motion.div
          key={`phrases[${currentPhrase}].dot`}
          style={{ left: dotLeft, scaleX: dotScaleX, color: dotColor }}
          className="absolute inset-y-0"
          variants={{
            initial: { left: "0%" },
            animate: {
              left: "100%",
              transformOrigin: "100% 50%",
              transition: {
                duration: animationDurationInSec,
                ease: [0.33, 1, 0.68, 1],
              },
            },
            exit: {
              left: "0%",
              transformOrigin: "0% 50%",
              transition: {
                duration: animationDurationInSec,
                ease: [0.32, 0, 0.67, 0],
              },
            },
          }}
          initial="initial"
          animate="animate"
          exit="exit"
        >
          .
        </motion.div>
      </AnimatePresence>
    </div>
  );
};
3
Finally, Update the import paths to match your project setup.

API reference

<TextSwitcher/>

PropsTypeDescriptionDefault value
phrases
string[]
An array of strings that the component will cycle through.
(required)
animationDurationInSec?
number
The duration, in seconds, of the enter and exit animations for each phrase.
0.4
readTimeInSec?
number
The display duration, in seconds, for each phrase before its exit animation starts.
2
style?
React.CSSProperties
Optional inline styles to apply to the component's root element.
undefined
className?
string
Optional class names to apply to the component's root element.
undefined