# Rotating Text

> Cycles words in a headline like swapping a neon sign insert: the old tube powers off, the next one strikes.

Category: Text. Docs: https://neonkit.heyaadi.com/components/rotating-text

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/rotating-text.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/rotating-text`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { RotatingText } from "@/components/ui/rotating-text"

<h1>
  Wire up <RotatingText words={["billing", "search", "auth"]} /> in an afternoon.
</h1>
```

## Examples

### Headline

The old insert powers off and slides out, the width follows, and the new tube strikes. Hover the headline to hold a word.

```tsx
import { RotatingText } from "@/components/ui/rotating-text";

export default function RotatingTextDemo() {
  return (
    <div className="w-full max-w-2xl text-center">
      <h3 className="text-[clamp(2.1rem,6.4vw,3.9rem)] font-semibold leading-[1.04] tracking-[-0.035em] text-white/90">
        Wire up{" "}
        <RotatingText
          words={["billing", "search", "auth", "webhooks"]}
          className="font-normal tracking-normal"
          style={{ fontFamily: "var(--font-tilt-neon)" }}
        />
        <br />
        in an afternoon.
      </h3>
      <p className="mx-auto mt-5 max-w-md text-[15px] leading-relaxed text-white/55">
        Drop-in modules for Next.js 16. Each one ships with its migration, 40-odd tests and a kill switch.
      </p>
    </div>
  );
}
```

### Controlled

index and onIndexChange keep the price in step; picking a plan pins it.

```tsx
"use client";

import * as React from "react";
import { RotatingText } from "@/components/ui/rotating-text";

const plans = [
  { audience: "solo developers", price: "$0", note: "3 projects, community support" },
  { audience: "teams of ten", price: "$96", note: "per month, unlimited projects" },
  { audience: "500-seat orgs", price: "Custom", note: "SSO, audit log, 99.99% SLA" },
];

export default function RotatingTextControlled() {
  const [index, setIndex] = React.useState(0);
  const [pinned, setPinned] = React.useState(false);

  return (
    <div className="w-full max-w-lg">
      <p className="text-[clamp(1.6rem,4.6vw,2.4rem)] font-semibold leading-tight tracking-[-0.03em] text-white/90">
        Priced for{" "}
        <RotatingText
          words={plans.map((p) => p.audience)}
          index={index}
          onIndexChange={setIndex}
          paused={pinned}
          interval={3200}
        />
      </p>
      <div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1">
        <span className="font-mono text-2xl tabular-nums text-white/90">{plans[index].price}</span>
        <span className="text-sm text-white/55">{plans[index].note}</span>
      </div>
      <div role="group" aria-label="Plan" className="mt-5 flex gap-1.5">
        {plans.map((plan, i) => (
          <button
            key={plan.audience}
            type="button"
            aria-pressed={i === index}
            onClick={() => {
              setIndex(i);
              setPinned(true);
            }}
            className="h-8 cursor-pointer rounded-md border border-white/10 px-3 text-[13px] text-white/60 transition-colors hover:text-white/90 aria-pressed:border-[rgb(var(--neon)/0.5)] aria-pressed:text-[rgb(var(--neon))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
          >
            {["Solo", "Team", "Org"][i]}
          </button>
        ))}
      </div>
    </div>
  );
}
```

### Sign insert

```tsx
import { NeonText } from "@/components/ui/neon-text";
import { RotatingText } from "@/components/ui/rotating-text";

export default function RotatingTextSign() {
  return (
    <div className="grid w-full max-w-md justify-items-center gap-3 rounded-2xl border border-white/[0.06] bg-[#07080d] px-6 py-10 text-center">
      <NeonText as="p" color="white" flicker={false} className="text-[2.6rem] leading-none" style={{ fontFamily: "var(--font-tilt-neon)" }}>
        Kitchen open
      </NeonText>
      <RotatingText
        color="pink"
        words={["until 2 a.m.", "on Sundays", "for takeaway"]}
        interval={3000}
        className="text-[2.6rem] leading-none"
        style={{ fontFamily: "var(--font-tilt-neon)" }}
      />
      <p className="mt-3 text-[13px] text-white/50">Bánh mì, ramen and two kinds of curry · <span className="whitespace-nowrap">41 Kreuzbergstraße</span></p>
    </div>
  );
}
```

### In a sentence

flicker={false} fades the new word up instead of striking it.

```tsx
import { RotatingText } from "@/components/ui/rotating-text";

export default function RotatingTextFade() {
  return (
    <p className="max-w-md text-[17px] leading-relaxed text-white/70">
      Your agent replied to 1,204 tickets this week and escalated 31, mostly about{" "}
      <RotatingText flicker={false} words={["refunds", "SSO setup", "invoice VAT"]} interval={2400} className="font-medium" />.
    </p>
  );
}
```

## API reference

### RotatingText

An inline span. It takes its font from the text around it. Also accepts span attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` | `string[]` | — | The inserts it cycles through. |
| `interval` | `number` | `2600` | Milliseconds each word stays lit. |
| `index` | `number` | — | The lit word, controlled. |
| `defaultIndex` | `number` | `0` | The first lit word, uncontrolled. |
| `onIndexChange` | `(index: number) => void` | — | Fires when the timer moves on. |
| `paused` | `boolean` | `false` | Hold the current word. |
| `flicker` | `boolean` | `true` | Strike the new word like a tube. Off, it fades up. |
| `live` | `boolean` | `false` | Announce each new word in a polite live region. |
| `color` | `NeonColor` | `inherited` | Color of the lit word. |

## Accessibility

- By default screen readers get every word once, in place, so the sentence reads 'Wire up billing, search, auth in an afternoon' and nothing interrupts later. live announces each new word through aria-live='polite' instead; use it when the word is news, not decoration.
- It holds while the pointer is over the line, while focus is inside it, while the tab is hidden and while it's off screen, so nobody is reading a word as it leaves.
- Each word's width is measured once (and again if the font loads late); the box eases between widths so the rest of the line glides instead of jumping.
- With reduced motion words cross-fade with no flicker, slide or width animation.

## Source

`components/ui/rotating-text.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Every word is two stacked layers: tinted glass (the unlit tube) and the lit tube over it. A swap powers
// the old tube off, slides its glass out, slides the new glass in while the width follows, then strikes
// the new tube with a stutter. Only opacity and transform animate; the width eases once per swap.
const css = `
.nk-rt-word { position: absolute; left: 0; top: 0; white-space: nowrap; opacity: 0; }
.nk-rt-word[data-state="on"] { opacity: 1; }
.nk-rt-word[data-state="in"] { animation: nk-rt-glass-in 320ms cubic-bezier(0.23, 1, 0.32, 1) 180ms both; }
.nk-rt-word[data-state="out"] { animation: nk-rt-glass-out 300ms cubic-bezier(0.23, 1, 0.32, 1) 160ms both; }
.nk-rt-word[data-state="in"] .nk-rt-lit { animation: nk-rt-strike 820ms linear 420ms both; }
.nk-rt-word[data-state="out"] .nk-rt-lit { animation: nk-rt-kill 200ms linear both; }
.nk-rt-root[data-flicker="false"] .nk-rt-word[data-state="in"] .nk-rt-lit { animation: nk-rt-fade 260ms cubic-bezier(0.23, 1, 0.32, 1) 380ms both; }
@keyframes nk-rt-glass-in { from { opacity: 0; transform: translateY(0.18em); } to { opacity: 1; transform: none; } }
@keyframes nk-rt-glass-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-0.18em); } }
@keyframes nk-rt-strike {
  0% { opacity: 0; } 9% { opacity: 1; } 16% { opacity: 0.08; } 27% { opacity: 0.85; } 34% { opacity: 0.2; } 52%, 100% { opacity: 1; }
}
@keyframes nk-rt-kill { 0% { opacity: 1; } 30% { opacity: 0.25; } 45% { opacity: 0.7; } 100% { opacity: 0; } }
@keyframes nk-rt-fade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .nk-rt-root { transition: none !important; }
  .nk-rt-word[data-state="in"] { animation: nk-rt-fade 300ms ease-out both; }
  .nk-rt-word[data-state="out"] { animation: nk-rt-fade 200ms ease-out reverse both; }
  .nk-rt-word[data-state] .nk-rt-lit { animation: none; }
}
`;

const lit =
  "[color:color-mix(in_srgb,rgb(var(--neon))_30%,white)] [text-shadow:0_0_0.015em_rgb(255_255_255/CALC(0.9*var(--neon-glow,1))),0_0_max(1.5px,0.05em)_rgb(var(--neon)/var(--neon-glow,1)),0_0_max(4px,0.14em)_rgb(var(--neon)/var(--neon-glow,1)),0_0_0.38em_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))),0_0_0.9em_rgb(var(--neon)/CALC(0.4*var(--neon-glow,1)))]";
const glass = "text-[rgb(var(--neon)/0.16)] [text-shadow:0_-0.014em_0_rgb(255_255_255/0.08)]";

const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

export interface RotatingTextProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color" | "children"> {
  /** The inserts it cycles through. */
  words: string[];
  /** Milliseconds each word stays lit. */
  interval?: number;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** The lit word, controlled. */
  index?: number;
  /** The first lit word, uncontrolled. */
  defaultIndex?: number;
  /** Fires when the timer moves to the next word. */
  onIndexChange?: (index: number) => void;
  /** Hold the current word. Hovering the line, focus inside it, a hidden tab and scrolling away also hold it. */
  paused?: boolean;
  /** Stutter on like a striking tube. Off, the new word fades up. */
  flicker?: boolean;
  /** Announce each new word to screen readers. Off, they read every word once, in place. */
  live?: boolean;
}

const RotatingText = React.forwardRef<HTMLSpanElement, RotatingTextProps>(
  (
    {
      className,
      style,
      color,
      words,
      interval = 2600,
      index: indexProp,
      defaultIndex = 0,
      onIndexChange,
      paused = false,
      flicker = true,
      live = false,
      ...props
    },
    forwardedRef,
  ) => {
    const rootRef = React.useRef<HTMLSpanElement>(null);
    const stageRef = React.useRef<HTMLSpanElement>(null);
    React.useImperativeHandle(forwardedRef, () => rootRef.current as HTMLSpanElement);

    const count = words.length;
    const [uncontrolled, setUncontrolled] = React.useState(defaultIndex);
    const controlled = indexProp !== undefined;
    const index = count ? ((((controlled ? indexProp : uncontrolled) % count) + count) % count) : 0;

    // The word going out, and whether the current one arrived by a swap (and so strikes) or was there first.
    const [previous, setPrevious] = React.useState(-1);
    const [swapped, setSwapped] = React.useState(false);
    const [shown, setShown] = React.useState(index);
    if (shown !== index) {
      setPrevious(shown);
      setSwapped(true);
      setShown(index);
    }

    const [widths, setWidths] = React.useState<number[] | null>(null);
    const [held, setHeld] = React.useState(false);

    useIsoLayoutEffect(() => {
      const stage = stageRef.current;
      if (!stage) return;
      const measure = () => {
        const next = Array.from(stage.children, (node) => (node as HTMLElement).offsetWidth);
        setWidths((prev) => (prev && prev.length === next.length && prev.every((w, i) => w === next[i]) ? prev : next));
      };
      measure();
      const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
      Array.from(stage.children).forEach((node) => ro?.observe(node));
      return () => ro?.disconnect();
    }, [words]);

    // Hold while the pointer is on the line, focus is inside it, the tab is hidden or it's off screen.
    React.useEffect(() => {
      const root = rootRef.current;
      if (!root) return;
      const line = root.parentElement ?? root;
      let hover = false;
      let focus = false;
      let inView = true;
      const sync = () => setHeld(hover || focus || !inView || document.hidden);
      const enter = () => {
        hover = true;
        sync();
      };
      const leave = () => {
        hover = false;
        sync();
      };
      const focusIn = () => {
        focus = true;
        sync();
      };
      const focusOut = (event: FocusEvent) => {
        if (!line.contains(event.relatedTarget as Node | null)) {
          focus = false;
          sync();
        }
      };
      const io =
        typeof IntersectionObserver === "undefined"
          ? null
          : new IntersectionObserver(([entry]) => {
              inView = entry.isIntersecting;
              sync();
            });
      io?.observe(root);
      line.addEventListener("pointerenter", enter);
      line.addEventListener("pointerleave", leave);
      line.addEventListener("focusin", focusIn);
      line.addEventListener("focusout", focusOut);
      document.addEventListener("visibilitychange", sync);
      return () => {
        io?.disconnect();
        line.removeEventListener("pointerenter", enter);
        line.removeEventListener("pointerleave", leave);
        line.removeEventListener("focusin", focusIn);
        line.removeEventListener("focusout", focusOut);
        document.removeEventListener("visibilitychange", sync);
      };
    }, []);

    const advance = React.useRef(() => {});
    advance.current = () => {
      const next = (index + 1) % count;
      if (!controlled) setUncontrolled(next);
      onIndexChange?.(next);
    };

    React.useEffect(() => {
      if (paused || held || count < 2) return;
      const timer = setTimeout(() => advance.current(), Math.max(800, interval));
      return () => clearTimeout(timer);
    }, [index, paused, held, count, interval]);

    // Once the old word is gone, drop it so it can come back later with a fresh animation.
    React.useEffect(() => {
      if (previous < 0) return;
      const timer = setTimeout(() => setPrevious(-1), 700);
      return () => clearTimeout(timer);
    }, [previous, index]);

    const width = widths?.[index];

    return (
      <span
        ref={rootRef}
        data-flicker={flicker ? undefined : "false"}
        style={neonVars(color, {
          width: width === undefined ? undefined : `${width}px`,
          ...style,
        })}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "nk-rt-root relative inline-block whitespace-nowrap text-left align-bottom",
          "transition-[width] delay-150 duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
          className,
        )}
        {...props}
      >
        <style>{css}</style>
        {live ? (
          <span className="sr-only" aria-live="polite" aria-atomic="true">
            {words[index]}
          </span>
        ) : (
          <span className="sr-only">{words.join(", ")}</span>
        )}
        {/* Holds the line height and baseline; its width only counts before the words are measured. */}
        <span aria-hidden className="invisible">
          {words[index]}
        </span>
        <span ref={stageRef} aria-hidden>
          {words.map((word, i) => (
            <span
              key={`${i}-${word}`}
              className="nk-rt-word"
              data-state={i === index ? (swapped ? "in" : "on") : i === previous ? "out" : undefined}
            >
              <span className={glass}>{word}</span>
              <span className={cn("nk-rt-lit absolute inset-0", lit)}>{word}</span>
            </span>
          ))}
        </span>
      </span>
    );
  },
);
RotatingText.displayName = "RotatingText";

export { RotatingText };
```
