Domphy

wordRotate

A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call wordRotate() with no arguments for a working demo, or edit the code below live.

Implementation notes

Single-item reactive keyed-array swap on a setInterval timer (reusing the same enter/exit crossfade state-machine pattern this file's morphingText.ts already established), driving motion()'s WAAPI initial/animate/exit for a vertical slide+fade rather than morphingText's goo-filter crossfade. Large/bold styling and 'reverses to white in dark mode' both come for free from themeSize(increase-4)/themeColor(shift-11) — no extra dark-mode code needed. transition{duration,easing} is the requested 'escape hatch' for the crossfade's own timing.

Status: ported · Reference: Magic UI original

// magicui "Word Rotate" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A single
// line of large, bold text that automatically cycles through a fixed list
// of words on a timer: the current word slides up and fades out while the
// next word slides in from below and fades in, at the same position, so
// surrounding layout never jumps. Fully automatic and looping — no
// interaction required.
//
// Structurally this is the same "single-item reactive keyed list" state
// machine `morphingText` uses in this file (replacing the one-item array on
// each tick lets the reconciler run the outgoing word's exit and the
// incoming word's enter at once), swapped from a gooey blur crossfade to a
// plain vertical slide-and-fade via `motion()`'s `initial`/`animate`/`exit`
// keyframes — an odometer/ticker-style word swap rather than a liquid morph.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { motion } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSize } from "@domphy/theme";

export interface WordRotateTransition {
  /** Milliseconds the slide/fade crossfade itself takes. Defaults to `350`. */
  duration?: number;
  /** CSS easing for the crossfade. Defaults to a spring-like ease-out curve. */
  easing?: string;
}

export interface WordRotateProps {
  /** Words/phrases cycled through in order, looping back to the first. Defaults to a short demo list. */
  words?: string[];
  /** Milliseconds each word stays visible before switching to the next. Defaults to `2500`. */
  duration?: number;
  /** Theme color for the word text. Defaults to `"neutral"` (theme foreground, flips light/dark automatically). */
  color?: ThemeColor;
  /** Escape hatch for the enter/exit crossfade's own timing/easing. See {@link WordRotateTransition}. */
  transition?: WordRotateTransition;
  /** Passthrough style merged onto the outer fixed-line container. */
  style?: StyleObject;
}

interface WordEntry {
  key: string;
  text: string;
}

const DEFAULT_WORDS = ["better", "faster", "modern", "reactive"];
// Vertical travel distance for the enter/exit slide, in em — scales with the
// word's own font size rather than a fixed pixel offset.
const SLIDE_DISTANCE_EM = 0.5;

function wordLayer(
  entry: WordEntry,
  color: ThemeColor,
  transitionDurationMs: number,
  easing: string,
): DomphyElement<"span"> {
  return {
    span: entry.text,
    _key: entry.key,
    style: {
      position: "absolute",
      insetInlineStart: 0,
      insetBlockStart: 0,
      whiteSpace: "nowrap",
      fontSize: (listener: Listener) => themeSize(listener, "increase-4"),
      fontWeight: () => "800",
      color: (listener: Listener) => themeColor(listener, "shift-11", color),
    },
    $: [
      motion({
        initial: { opacity: 0, y: `${SLIDE_DISTANCE_EM}em` },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: `-${SLIDE_DISTANCE_EM}em` },
        transition: { duration: transitionDurationMs, easing },
      }),
    ],
  };
}

/**
 * A single line of large, bold text that automatically and endlessly cycles
 * through a word list, sliding/fading the outgoing word out and the
 * incoming word in at the same fixed position. No interaction required.
 * Call with no arguments for a working demo cycling through a short word list.
 */
function wordRotate(props: WordRotateProps = {}): DomphyElement<"span"> {
  const words = props.words && props.words.length > 0 ? props.words : DEFAULT_WORDS;
  const holdDuration = props.duration ?? 2500;
  const color = props.color ?? "neutral";
  const transitionDurationMs = props.transition?.duration ?? 350;
  const easing = props.transition?.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)";

  const layers = toState<WordEntry[]>([{ key: "word-0", text: words[0] }]);
  let wordIndex = 0;
  let insertCount = 0;

  const advance = () => {
    if (words.length <= 1) return;
    wordIndex = (wordIndex + 1) % words.length;
    insertCount += 1;
    layers.set([{ key: `word-${insertCount}`, text: words[wordIndex] }]);
  };

  return {
    span: [
      {
        span: (listener: Listener) =>
          layers.get(listener).map((entry) => wordLayer(entry, color, transitionDurationMs, easing)),
        style: { position: "relative", display: "inline-block" },
      },
    ],
    style: {
      position: "relative",
      display: "inline-block",
      minHeight: "1.2em",
      minWidth: "1ch",
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || words.length <= 1) return;
      const timer = window.setInterval(advance, holdDuration);
      node.addHook("Remove", () => window.clearInterval(timer));
    },
  } as DomphyElement<"span">;
}

export { wordRotate };

← Back to Magic UI catalog