Workspace / component / text

Rotate In Text

Cycles through a list of phrases, rotating each one in on the x-axis like a departure board settling. Built for hero subheads that name audiences.

Workspacecomponenttext

paxfx add blocks/component-text-rotate-inlocal only

Tier: low. Reduced motion: Phrases cross-fade with no rotation.

"use client";

import { useEffect, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";

export interface RotateInTextProps {
  /** Static text rendered before the rotating slot. */
  lead?: string;
  /** Phrases cycled through the slot. */
  phrases?: string[];
  /** Seconds each phrase holds before the next rotates in. */
  hold?: number;
  /** Seconds for one phrase's rotation. */
  duration?: number;
  /** Heading level or inline element to render. */
  as?: "h1" | "h2" | "h3" | "p" | "span";
  className?: string;
}

const DEFAULT_PHRASES = ["designers.", "founders.", "engineers."];

/**
 * Cycles through a list of phrases, rotating each one in on the x-axis
 * like a departure board settling. All phrases are stacked invisibly to
 * reserve the widest slot, so the line never reflows between swaps. One
 * rAF loop advances the index and cancels on unmount; under reduced
 * motion phrases cross-fade with no rotation.
 */
export function RotateInText({
  lead = "Built for",
  phrases = DEFAULT_PHRASES,
  hold = 2.2,
  duration = 0.6,
  as: Tag = "p",
  className,
}: RotateInTextProps) {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
  const [index, setIndex] = useState(0);

  useEffect(() => {
    if (!inView || phrases.length <= 1) return;

    let raf = 0;
    let last = performance.now();
    let acc = 0;
    const periodMs = (hold + duration) * 1000;

    const step = (now: number) => {
      raf = requestAnimationFrame(step);
      acc += Math.min(now - last, 100);
      last = now;
      if (acc >= periodMs) {
        acc = 0;
        setIndex((i) => (i + 1) % phrases.length);
      }
    };

    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [inView, phrases.length, hold, duration]);

  return (
    <Tag
      className={cn("m-0 text-lg font-medium tracking-tight", className)}
      data-pax-asset="component-text-rotate-in"
    >
      <style>{`
        @keyframes pax-rotate-in-flip {
          0% { transform: rotateX(-92deg); opacity: 0; }
          100% { transform: rotateX(0deg); opacity: 1; }
        }
        @keyframes pax-rotate-in-fade {
          0% { opacity: 0; }
          100% { opacity: 1; }
        }
      `}</style>
      <span className="sr-only">
        {lead} {phrases.join(", ")}
      </span>
      <span ref={ref} aria-hidden="true">
        {lead}
        {"\u00A0"}
        <span className="inline-grid align-bottom [perspective:640px]">
          {phrases.map((phrase, i) => (
            <span
              key={`${phrase}-${i}`}
              className={cn(
                "col-start-1 row-start-1 font-semibold whitespace-nowrap [transform-origin:50%_100%]",
                i === index ? "" : "invisible",
              )}
              style={
                i === index && inView
                  ? {
                      animation: reduced
                        ? `pax-rotate-in-fade ${duration}s ease-out both`
                        : `pax-rotate-in-flip ${duration}s cubic-bezier(0.22, 1, 0.36, 1) both`,
                    }
                  : undefined
              }
            >
              {phrase}
            </span>
          ))}
        </span>
      </span>
    </Tag>
  );
}