Content·10.5
Text Loop
One line, rotating, in a box that never moves.
Resolving 412 modules
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/text-loop.jsonUsage
"use client";
import { TextLoop } from "@/components/yugo/text-loop";
const STEPS = [
"Resolving 412 modules",
"Type-checking 68 files",
"Bundling the client",
"Uploading to the edge",
];
export function DeployLine({ running }: { running: boolean }) {
return (
<p className="text-[13px] font-medium">
<TextLoop play={running} interval={1.8}>
{STEPS.map((step) => (
<span key={step}>{step}</span>
))}
</TextLoop>
</p>
);
}Source
"use client";
import { Children, useCallback, useEffect, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;
const INSTANT = { duration: 0 } as const;
const ENTER = 0.28;
const EXIT = 0.16;
const FROM = { opacity: 0, y: 10, filter: "blur(6px)" } as const;
const HERE = { opacity: 1, y: 0, filter: "blur(0px)" } as const;
const AWAY = { opacity: 0, y: -6, filter: "blur(3px)" } as const;
const ALIGN = {
start: "justify-items-start",
center: "justify-items-center",
end: "justify-items-end",
} as const;
export type TextLoopAlign = keyof typeof ALIGN;
export type UseTextLoopOptions = {
length: number;
interval?: number;
play?: boolean;
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
};
export type UseTextLoopResult<T extends HTMLElement> = {
ref: React.RefObject<T | null>;
index: number;
running: boolean;
go: (index: number) => void;
next: () => void;
prev: () => void;
};
/**
* The rotation, with nothing drawn. It advances only while the loop is on
* screen and the tab is in front: a belt nobody is watching should not be
* costing frames.
*/
export function useTextLoop<T extends HTMLElement = HTMLSpanElement>({
length,
interval = 2,
play = true,
index: controlled,
defaultIndex = 0,
onIndexChange,
}: UseTextLoopOptions): UseTextLoopResult<T> {
const ref = useRef<T>(null);
const inView = useInView(ref);
const [uncontrolled, setUncontrolled] = useState(defaultIndex);
const [awake, setAwake] = useState(true);
const raw = controlled ?? uncontrolled;
const index = length > 0 ? ((raw % length) + length) % length : 0;
const emit = useRef(onIndexChange);
emit.current = onIndexChange;
const live = useRef({ index, length, controlled });
live.current = { index, length, controlled };
const go = useCallback((next: number) => {
const { length: total, controlled: outside } = live.current;
if (total <= 0) return;
const at = ((next % total) + total) % total;
if (outside === undefined) setUncontrolled(at);
emit.current?.(at);
}, []);
const step = useCallback(
(delta: number) => go(live.current.index + delta),
[go],
);
const next = useCallback(() => step(1), [step]);
const prev = useCallback(() => step(-1), [step]);
useEffect(() => {
const read = () => setAwake(!document.hidden);
read();
document.addEventListener("visibilitychange", read);
return () => document.removeEventListener("visibilitychange", read);
}, []);
const running = play && awake && inView && length > 1 && interval > 0;
useEffect(() => {
if (!running) return;
const timer = setInterval(() => step(1), interval * 1000);
return () => clearInterval(timer);
}, [running, interval, step]);
return { ref, index, running, go, next, prev };
}
export type TextLoopProps = Omit<UseTextLoopOptions, "length"> & {
children: React.ReactNode;
align?: TextLoopAlign;
className?: string;
};
export function TextLoop({
children,
align = "start",
className = "",
...options
}: TextLoopProps) {
const reduced = useReducedMotion();
const items = Children.toArray(children);
const { ref, index } = useTextLoop<HTMLSpanElement>({
...options,
length: items.length,
});
return (
<span
ref={ref}
className={`inline-grid ${ALIGN[align]} text-stone-700 dark:text-stone-200 ${className}`}
>
{/* Every phrase, drawn once and invisibly, so the cell is already as
wide and as tall as the longest one and nothing around it reflows
when the rotation lands on it. */}
{items.map((item, i) => (
<span
key={`slot-${i}`}
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{item}
</span>
))}
<AnimatePresence initial={false} mode="sync">
<motion.span
key={index}
className="col-start-1 row-start-1 whitespace-nowrap"
initial={reduced ? false : FROM}
animate={HERE}
exit={
reduced
? { opacity: 0, transition: INSTANT }
: { ...AWAY, transition: { duration: EXIT, ease: LEAVE } }
}
transition={reduced ? INSTANT : { duration: ENTER, ease: EASE }}
>
{items[index]}
</motion.span>
</AnimatePresence>
</span>
);
}Props
childrenReactNodeThe phrases to rotate through. Each child is one frame; anything renderable works, not just strings.
interval2numberSeconds a phrase holds before the next one arrives.
playtruebooleanWhether the rotation runs. False parks it on the current phrase without unmounting anything.
indexnumberControlled position. Supplying it makes the parent the source of truth and the timer only reports.
defaultIndex0numberUncontrolled starting phrase.
onIndexChange(index: number) => voidFires with the next index on every advance, controlled or not.
align"start""start" | "center" | "end"Where a phrase sits in the reserved cell, which matters because the cell is as wide as the longest one.
className""stringAppended last to the outer span, so the type size and colour are the caller's.