# Text Cycle

> Cycles headline words with a per-character blur stagger.

A shadcn/ui block from the [@wensity](https://ui.wensity.com/components) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/wensity/text-cycle).

- Page, with a live preview: https://shadcn.exchange/r/wensity/text-cycle
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/wensity/text-cycle-light-651cbb56d58859c2.png
- Registry item: https://raw.githubusercontent.com/wensity/registry/main/text-cycle.json
- Categories: Text Animations, wensity, free
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://raw.githubusercontent.com/wensity/registry/main/text-cycle.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `clsx`, `framer-motion`, `tailwind-merge`

## Notes from the registry

Free Wensity component. Installs to @components/wensity/text-cycle.tsx. For Pro components and updates, use pnpm dlx wensity@latest add text-cycle.

## Files

### `registry/wensity/lib/utils.ts`

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### `registry/wensity/text-cycle.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";

export interface TextCycleProps {
  /** Words to cycle through. */
  words?: string[];
  /** Hold time per word before advancing, in milliseconds. */
  interval?: number;
  /** Outer layout class (size, weight, tracking, color). */
  className?: string;
  /** Class applied to each character. */
  charClassName?: string;
  /** Pause the cycle. */
  paused?: boolean;
  /** Element to render, such as "h1" or "h2" for a heading. Default: `"span"`. */
  as?: keyof React.JSX.IntrinsicElements;
}

const DEFAULT_WORDS = ["engineer", "developer", "designer"] as const;

let graphemeSegmenter: Intl.Segmenter | null = null;

function getGraphemeSegmenter(): Intl.Segmenter | null {
  if (typeof Intl === "undefined" || !("Segmenter" in Intl)) return null;
  graphemeSegmenter ??= new Intl.Segmenter("en", { granularity: "grapheme" });
  return graphemeSegmenter;
}

function splitIntoCharacters(value: string): string[] {
  const segmenter = getGraphemeSegmenter();
  if (segmenter) {
    return Array.from(segmenter.segment(value), ({ segment }) => segment);
  }
  return Array.from(value);
}

/**
 * TextCycle
 *
 * Cycles words on an interval with a per-character blur + slide stagger on
 * enter and exit. Reduced-motion safe: shows the current word statically.
 */
export function TextCycle({
  words: wordsProp,
  interval = 2500,
  className,
  charClassName,
  paused = false,
  as = "span",
}: TextCycleProps) {
  const Tag = as as unknown as React.ComponentType<
    React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement | null> }
  >;
  const words = React.useMemo(() => {
    const cleaned = (wordsProp ?? [...DEFAULT_WORDS])
      .map((word) => word.trim())
      .filter(Boolean);
    return cleaned.length > 0 ? cleaned : [...DEFAULT_WORDS];
  }, [wordsProp]);

  const [index, setIndex] = React.useState(0);
  const [reduceMotion, setReduceMotion] = React.useState(false);

  React.useEffect(() => {
    if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
      return;
    }
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => setReduceMotion(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    if (paused || reduceMotion || words.length <= 1) return;

    const hold = Math.max(400, interval);
    const timer = window.setInterval(() => {
      setIndex((prev) => (prev + 1) % words.length);
    }, hold);

    return () => window.clearInterval(timer);
  }, [interval, paused, reduceMotion, words.length]);

  const safeIndex = index % words.length;
  const current = words[safeIndex] ?? "";
  const chars = splitIntoCharacters(current);

  if (reduceMotion) {
    return (
      <Tag className={cn("inline-flex", className)} aria-live="polite" aria-atomic="true">
        {current}
      </Tag>
    );
  }

  return (
    <Tag className={cn("inline-flex", className)}>
      <span className="sr-only" aria-live="polite" aria-atomic="true">
        {current}
      </span>
      <AnimatePresence mode="popLayout">
        <motion.span
          key={safeIndex}
          className="inline-flex gap-[0.5px] py-[0.12em] leading-none"
          initial={{ opacity: 0, y: 5 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -5 }}
          transition={{ duration: 0.4 }}
          aria-hidden="true"
        >
          {chars.map((char, i) => (
            <motion.span
              key={`${safeIndex}-${i}`}
              className={cn("inline-block", charClassName)}
              initial={{ opacity: 0, y: 5, filter: "blur(5px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, y: -5, filter: "blur(5px)" }}
              transition={{
                delay: i * 0.03,
                duration: 0.3,
              }}
            >
              {char === " " ? "\u00A0" : char}
            </motion.span>
          ))}
        </motion.span>
      </AnimatePresence>
    </Tag>
  );
}
```
