Workspace / component / text
Rotate In Text
paxfx add blocks/component-text-rotate-inlocal onlyTier: 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>
);
}