Text Rotator
One line of text at a time: the next one moves up to take its place every few seconds.
$npx shadcn@latest add https://framebrick.co/r/text-rotator.jsonFrom the Marai Neesu template, see it in context.
Hi, I'm Alex Rivera
Product Designer, Web Designer, Solopreneur, Digital Creatorimport { TextRotator } from "@/components/ui/text-rotator";
const roles = ["Product Designer", "Web Designer", "Solopreneur", "Digital Creator"];
export default function TextRotatorDemo() {
return (
<div className="text-center">
<p className="text-sm text-muted-foreground">Hi, I'm Alex Rivera</p>
<TextRotator items={roles} className="text-2xl font-semibold tracking-tight" />
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/text-rotator.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/text-rotator.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Text Rotator — one line of text at a time, the next one moving up to take its place every few seconds: the roles under the * Marai Neesu greeting ("Product Designer", "Web Designer", "Solopreneur", "Digital Creator"). It is Framer's slideshow with * one text per slide, moving up. * * Every `interval` the column moves one line plus `gap` up on Framer's slideshow spring (stiffness 200, damping 40, mass 1: * over-damped, so it eases in and settles without overshoot), forever: a copy of the first text waits under the last, and * when the move reaches it the column jumps back to the start without a transition, which nobody can see. The spring is a * CSS linear() curve sampled from the spring itself (tools/ui/spring-curve.mjs in the Framebrick workspace; within 2% of it) * over 1121 ms; a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1). The move is a CSS transition, so there is * no animation library, and the first text is in the server HTML. * * It sizes itself from the text: the window is one line tall (1lh, the line height it inherits, so a bigger font or a * responsive size just works) and as wide as the widest text, so the words around it never move. `lh` needs Chrome 109, * Safari 16.4 or Firefox 120. * * Reduced motion: Marai Neesu's keeps changing and so does this one, but each step is a jump (the transition is off), as a * change of text rather than a movement. It waits while the tab is hidden or the rotator is scrolled out of view. All the * texts are in the markup once, visually hidden, for assistive technology; the moving copy is hidden from it, so nothing is * read twice and nothing is announced as it changes. */ /* One move takes 1121ms (the duration-1121 below); the interval must leave room for it and for the jump back. */ const MOVE = 1121; const MIN_INTERVAL = 1400; const move = "transition-transform duration-1121 ease-[cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:ease-[linear(0,0.093,0.247,0.392,0.513,0.611,0.69,0.753,0.803,0.843,0.875,0.9,0.92,0.936,0.949,0.96,0.968,0.974,0.98,0.984,0.987,0.99,0.992,0.993,0.995,0.996,0.997,0.997,0.998,1)] motion-reduce:transition-none"; export type TextRotatorProps = { /** The texts, in order. With fewer than two nothing moves. */ items: string[]; /** Milliseconds each text stays (not less than 1400, the time a move takes). */ interval?: number; /** Space between two texts in the column, in px. It only shows during a move. */ gap?: number; className?: string; }; export function TextRotator({ items, interval = 4000, gap = 10, className }: TextRotatorProps) { const count = items.length; const root = React.useRef<HTMLSpanElement>(null); /* 0 to count: `count` is the copy of the first text under the last one. */ const [index, setIndex] = React.useState(0); const [jumping, setJumping] = React.useState(false); const [inView, setInView] = React.useState(true); const [hidden, setHidden] = React.useState(false); const running = count > 1 && inView && !hidden; /* The list may shrink under it: never point past the copy at the end. */ const position = Math.min(index, count); React.useEffect(() => { const element = root.current; if (!element) return; const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting)); observer.observe(element); const visibility = () => setHidden(document.visibilityState === "hidden"); document.addEventListener("visibilitychange", visibility); return () => { observer.disconnect(); document.removeEventListener("visibilitychange", visibility); }; }, []); React.useEffect(() => { if (!running) return; const timer = window.setInterval(() => setIndex((i) => (i >= count ? i : i + 1)), Math.max(interval, MIN_INTERVAL)); return () => window.clearInterval(timer); }, [running, count, interval]); /* On the copy of the first text: wait for the move to end, then go back to the real one with no transition. */ React.useEffect(() => { if (count < 2 || position !== count) return; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let frame = 0; const timer = window.setTimeout( () => { setJumping(true); setIndex(0); frame = requestAnimationFrame(() => (frame = requestAnimationFrame(() => setJumping(false)))); }, reduced ? 0 : MOVE, ); return () => { window.clearTimeout(timer); cancelAnimationFrame(frame); }; }, [position, count]); if (count === 0) return null; return ( <span ref={root} className={cn("relative inline-block h-[1lh] overflow-hidden align-bottom", className)}> <span className="sr-only">{items.join(", ")}</span> <span aria-hidden className={cn("flex flex-col", !jumping && move)} style={{ gap, transform: `translateY(calc(${position} * -1 * (1lh + ${gap}px)))` }} > {[...items, items[0]].map((item, i) => ( <span key={i} className="block h-[1lh] whitespace-nowrap"> {item} </span> ))} </span> </span> ); }
Usage
import { TextRotator } from "@/components/ui/text-rotator";
<p className="text-2xl font-semibold">
I am a <TextRotator items={["Product Designer", "Web Designer", "Solopreneur"]} />
</p>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | string[] | — | The texts, in order. With fewer than two nothing moves. |
| interval | number | 4000 | Milliseconds each text stays; not less than 1400, the time a move takes. |
| gap | number | 10 | Space between two texts in the column, in px. It only shows during a move. |
| className | string | — | Classes for the window: the text size and weight go here. |
- It sizes itself from the text: the window is one line tall (1lh, the line height it inherits) and as wide as the widest text, so the words around it never move. It needs the lh unit: Chrome 109, Safari 16.4 or Firefox 120.
- A CSS transition on Framer's slideshow spring (stiffness 200, damping 40, mass 1: over-damped, no overshoot) as a linear() curve within 2% of it, over 1121 ms. No animation library, and the first text is in the server HTML.
- Forever: a copy of the first text waits under the last, and when the move reaches it the column jumps back to the start with no transition, which nobody can see.
- Reduced motion: it keeps changing, as Marai Neesu's does, but each step is a jump. It waits while the tab is hidden or the rotator is scrolled out of view.
- All the texts are in the markup once, visually hidden, for assistive technology; the moving copy is aria-hidden, so nothing is read twice and nothing is announced as it changes.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called TextRotator — a client component, no animation library. - Props: items (string[]), interval (ms, default 4000, not less than 1400), gap (px, default 10), className. - A root span: relative inline-block, height 1lh (the line height it inherits), overflow-hidden, align-bottom. Inside it a visually hidden span (sr-only) with the items joined by ", ", and an aria-hidden flex-col column of the items plus a copy of the first one at the end, each item block, height 1lh, whitespace-nowrap, the column's gap in px. - Every interval the column moves one step up: transform translateY(calc(index * -1 * (1lh + gap))), with a CSS transition on transform of 1121ms on a spring (stiffness 200, damping 40, mass 1) written as a linear() curve, cubic-bezier(0.22, 1, 0.36, 1) as the fallback. Reduced motion: no transition, so each step is a jump; it keeps going. - When the index reaches the copy of the first item, wait 1121ms (0 with reduced motion), switch the transition off, set the index to 0, and switch it on again two animation frames later: a seamless loop. - Pause while the tab is hidden (visibilitychange) or the rotator is out of view (IntersectionObserver). The first item must be in the server HTML.











