Skip to the content

Library/ 08 word-loopText

Word Loop

One word in your headline keeps changing its mind.

It plays on load: press Replay to see it again

Settings

2500
600

What it does

Word Loop is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called word rotate, rotating text or flip words.

A single word flips through a list of alternatives, each one rising into place as the previous slides away. The sentence around it never shifts, the loop pauses offscreen, and screen readers hear the full list once.

Documentation

One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.

  1. Copy the prompt

  2. Ask for it

    For instance: Add this word loop effect to the hero of my home page.
    What it sends
    Markdown
    # Foundry44 · Word Loop
    
    One word in a sentence cycles through alternatives with a vertical swap.
    
    Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.
    
    ## Markup it expects
    
    The element marked `data-f44="word-loop"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `wordLoop()` or as props.
    
    ```html
    <span data-f44="word-loop">slow mornings, long shade, wet winters, quiet streets</span>
    ```
    
    ## Options
    
    | Option | Attribute | Type | Default | Description |
    |---|---|---|---|---|
    | `words` | `data-f44-words` | `string[] | string` | `[]` | The words to cycle through, as an array or a comma-separated string. Defaults to the element's text, split on commas. |
    | `interval` | `data-f44-interval` | `number` | `2500` | Time in ms each word stays before the next one replaces it. |
    | `duration` | `data-f44-duration` | `number` | `600` | Time in ms for one swap. |
    
    ## Setup
    
    - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/word-loop/word-loop.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="word-loop"` element and reads the `data-f44-*` attributes above.
    - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/word-loop.json` writes `lib/word-loop-core.ts` and `components/ui/word-loop.tsx`, then use `<WordLoop />` with the options as props.
    - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { WordLoop } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { wordLoop } from "@foundry44/core/word-loop"`.
    - **React / Next.js by hand**: save the code below as `lib/word-loop-core.ts` and import it from there (`@/lib/word-loop-core` where that alias exists). Don't name it `word-loop.ts` next to a `WordLoop.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `wordLoop(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
    - **Any other stack**: create on mount with `wordLoop(element, options)`, `update(options)` when they change, `destroy()` on unmount.
    
    Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `wordLoop(element)` on each element.
    
    ## Pitfalls
    
    - Respects prefers-reduced-motion: keep that behaviour.
    - The element keeps the width of the longest word: in the middle of a sentence, shorter words leave a gap after them. The effect reads best at the end of a line.
    - Before the script runs, the element shows its own text. Writing the words there, separated by commas, gives a readable fallback.
    - In React, pass the words as `words`, not as children: the element's content is rebuilt.
    - Screen readers hear the whole list once, separated by commas, not each swap.
    
    ## Code: `word-loop-core.ts`
    
    ```ts
    // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
    /**
     * Foundry44 — word-loop
     * One word in a sentence cycles through a list of alternatives. The current
     * word slides up and out behind an invisible edge while the next one rises
     * from below. The element keeps the width of the longest word, so the rest of
     * the sentence never shifts. The loop pauses while the element is offscreen.
     *
     * Self-contained: no imports, so this file can be copied as-is.
     */
    
    export interface WordLoopOptions {
      /** The words to cycle through, as an array or a comma-separated string. Defaults to the element's text, split on commas. */
      words?: string[] | string;
      /** Time in ms each word stays before the next one replaces it. @range 800 8000 100 */
      interval?: number;
      /** Time in ms for one swap. @range 150 2000 50 */
      duration?: number;
    }
    
    export interface WordLoopInstance {
      update(options: WordLoopOptions): void;
      destroy(): void;
    }
    
    export const wordLoopDefaults: Required<WordLoopOptions> = {
      words: [],
      interval: 2500,
      duration: 600,
    };
    
    const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
    // Clips the top and bottom just outside the line, so descenders survive a tight line-height;
    // the sides are extended so italic overhangs are never cut.
    const MASK_CLIP = "inset(-0.08em -0.25em)";
    // Well past 100%: with a tight line-height, descenders and ascenders overflow the line box,
    // and a hidden word must clear the mask with them.
    const BELOW = "translateY(130%)";
    const ABOVE = "translateY(-130%)";
    
    const toList = (words: string[] | string) =>
      (typeof words === "string" ? words.split(",") : words).map((word) => word.trim()).filter(Boolean);
    
    export function wordLoop(el: HTMLElement, options: WordLoopOptions = {}): WordLoopInstance {
      let opts = { ...wordLoopDefaults, ...options };
    
      if (typeof window === "undefined") return { update() {}, destroy() {} };
      // Reduced motion keeps the rotation but drops the movement: words crossfade in place.
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
      const property = reduced ? "opacity" : "transform";
      const enterValue = reduced ? "0" : BELOW;
      const exitValue = reduced ? "0" : ABOVE;
      const shownValue = reduced ? "1" : "none";
    
      const originalStyle = el.getAttribute("style");
      // The original nodes are put back as-is on destroy() (frameworks keep references to them).
      const originalNodes = Array.from(el.childNodes);
      const textWords = toList(el.textContent ?? "");
      const currentList = () => {
        const list = toList(opts.words);
        return list.length ? list : textWords;
      };
    
      let spans: HTMLSpanElement[] = [];
      let listKey = "";
      let index = 0;
      let timer = 0;
      let visible = false;
      let observer: IntersectionObserver | null = null;
    
      // Every word sits in the same grid cell, so the element takes the width of the
      // longest one and swapping never reflows the sentence around it.
      el.style.display = "inline-grid";
      el.style.justifyItems = "start";
      el.style.clipPath = MASK_CLIP;
    
      const build = () => {
        const list = currentList();
        listKey = list.join("\n");
        index = 0;
        el.replaceChildren();
        // Screen readers get the whole list once instead of hearing every swap.
        const label = document.createElement("span");
        label.textContent = list.join(", ");
        label.style.cssText =
          "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
        el.appendChild(label);
        spans = list.map((word, i) => {
          const span = document.createElement("span");
          span.textContent = word;
          span.setAttribute("aria-hidden", "true");
          span.style.gridArea = "1 / 1";
          span.style.whiteSpace = "nowrap";
          span.style.setProperty(property, i === 0 ? shownValue : enterValue);
          el.appendChild(span);
          return span;
        });
      };
    
      const swap = () => {
        const from = spans[index];
        index = (index + 1) % spans.length;
        const to = spans[index];
        if (!from || !to) return;
        const transition = `${property} ${opts.duration}ms ${EASE_OUT}`;
        from.style.transition = transition;
        from.style.setProperty(property, exitValue);
        // Jump the incoming word below the edge first, so it always rises from the bottom.
        to.style.transition = "none";
        to.style.setProperty(property, enterValue);
        void to.offsetHeight;
        to.style.transition = transition;
        to.style.setProperty(property, shownValue);
        schedule();
      };
    
      const schedule = () => {
        window.clearTimeout(timer);
        timer = 0;
        if (visible && spans.length > 1) timer = window.setTimeout(swap, opts.interval);
      };
    
      const setVisible = (next: boolean) => {
        visible = next;
        schedule();
      };
    
      build();
      if (typeof IntersectionObserver === "undefined") {
        setVisible(true);
      } else {
        observer = new IntersectionObserver((entries) => {
          for (const entry of entries) setVisible(entry.isIntersecting);
        });
        observer.observe(el);
      }
    
      return {
        update(next) {
          const previous = opts;
          opts = { ...opts, ...next };
          // React passes a new array on every render: rebuild only when the words really change.
          if (currentList().join("\n") !== listKey) {
            build();
            schedule();
          } else if (opts.interval !== previous.interval) {
            schedule();
          }
          // duration is read at each swap.
        },
        destroy() {
          window.clearTimeout(timer);
          observer?.disconnect();
          observer = null;
          el.replaceChildren(...originalNodes);
          if (originalStyle === null) el.removeAttribute("style");
          else el.setAttribute("style", originalStyle);
          spans = [];
        },
      };
    }
    ```