yugodesign

Content·10.5

Text Loop

One line, rotating, in a box that never moves.

deploy · yugo.click

Resolving 412 modules

text-loop

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/text-loop.json

Usage

stats.tsx
"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

components/yugo/text-loop.tsx
"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

children
ReactNode

The phrases to rotate through. Each child is one frame; anything renderable works, not just strings.

interval2
number

Seconds a phrase holds before the next one arrives.

playtrue
boolean

Whether the rotation runs. False parks it on the current phrase without unmounting anything.

index
number

Controlled position. Supplying it makes the parent the source of truth and the timer only reports.

defaultIndex0
number

Uncontrolled starting phrase.

onIndexChange
(index: number) => void

Fires 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""
string

Appended last to the outer span, so the type size and colour are the caller's.