Domphy

containerTextFlip

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

Implementation notes

Full structure/behavior match: bordered dataTone chip badge that width-tweens (canvas measureText -> reactive MotionKeyframe -> motion() patch) to hug each new word, with per-character enter/exit staggered via individual motion() instances (transition.delay = index * stagger). Old/new words are kept perfectly overlapping via a wholesale-replaced single-entry reactive list of absolutely-positioned word layers (same technique as the sibling layoutTextFlip.ts), each containing its own set of keyed per-character spans so the reveal genuinely ripples per-glyph rather than crossfading as one block. Exact badge border/shadow/background tokens are this implementation's own reasonable design choice (edge-anchored dataTone chip) since upstream's rendered demo styling wasn't inspectable, per the task's own researchNote — moderate confidence on precise visual tokens, high confidence on structure/behavior. The surrounding demo sentence ('Ship your product [word].') is a fixed literal, not a prop, matching the spec's own prop list (words/interval/animationDuration/className/textClassName/startIndex only).

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Container Text Flip" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). An
// inline word-flip effect: a small bordered pill/badge sits inside a larger
// headline sentence and cycles through a list of words, smoothly resizing
// its own width to hug each new word while the outgoing word's letters
// fade/slide out and the incoming word's letters fade/slide in with a small
// per-character stagger — a rolling reveal rather than an instant swap.
//
// Distinct from this file's sibling `layoutTextFlip.ts` (which crossfades
// the WHOLE outgoing/incoming word as a single unit): here every glyph is
// its own keyed `<span>`, each with its own `motion()` instance whose
// `transition.delay = index * staggerMs` — so the reveal visibly ripples
// left-to-right across the word instead of fading in as one block, per this
// component's own domSketch ("row of per-character spans... individually
// mounted/unmounted"). To keep the outgoing and incoming WORDS perfectly
// overlapping while their own letters animate independently (rather than
// the old word's tail letters briefly sitting in normal flow next to the
// new word's lead letters), each word is still wrapped in one
// `position:absolute; inset:0` layer — the same "wholesale-replace a single
// reactive list entry" technique `layoutTextFlip.ts`'s own `wordLayer()`
// uses — just with an array of per-character children inside it instead of
// one text node.
//
// The badge's own width tween reuses `layoutTextFlip`'s measured-width
// technique verbatim: an offscreen `canvas.measureText()` call (using the
// badge's own resolved font, read once via `getComputedStyle` on mount)
// drives a reactive `MotionKeyframe` State fed into a `motion()` instance on
// the badge itself, so the Web Animations API always tweens FROM the
// badge's current rendered width with no extra bookkeeping.
//
// FIDELITY NOTE (per the task's own researchNote): the upstream docs page
// only exposed the props table, not the rendered demo's computed styling —
// the badge surface below (edge-anchored `dataTone` chip, border, shadow,
// "a light neutral surface distinct from the page background") is this
// implementation's own reasonable design choice, not a confirmed 1:1 visual
// match.

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

export interface ContainerTextFlipProps {
  /** Words cycled through inside the badge, looping back to the first. Defaults to a short demo list. */
  words?: string[];
  /** Milliseconds each word is held before advancing to the next. Defaults to `3000`. */
  interval?: number;
  /** Milliseconds for the badge's width tween AND the per-character letter transition (kept as one
   * shared knob, per the spec's own "separately configurable from the hold interval" framing —
   * separate from `interval`, shared between width and letters). Defaults to `700`. */
  animationDuration?: number;
  /** Index into `words` shown first, before the first scheduled advance. Defaults to `0`. */
  startIndex?: number;
  /** Theme color family for the badge's own elevated surface. Defaults to `"neutral"`. */
  badgeColor?: ThemeColor;
  /** Extra class name merged onto the badge wrapper's native `class` attribute. */
  className?: string;
  /** Extra class name merged onto each character span's native `class` attribute. */
  textClassName?: string;
  /** Passthrough style merged onto the badge wrapper. */
  style?: StyleObject;
}

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

const DEFAULT_WORDS = ["faster", "cleaner", "smarter", "sharper"];
const HORIZONTAL_PADDING_UNITS = 3;
const SLIDE_DISTANCE_EM = 0.3;
const PER_CHARACTER_STAGGER_RATIO = 0.12; // fraction of animationDuration between adjacent characters' start
const EASE_OUT_EXPO = "cubic-bezier(0.16, 1, 0.3, 1)";

let containerTextFlipInstanceCounter = 0;

function characterLayer(
  char: string,
  index: number,
  wordKey: string,
  staggerMs: number,
  transitionDurationMs: number,
  textClassName?: string,
): DomphyElement<"span"> {
  return {
    span: char === " " ? " " : char,
    _key: `${wordKey}-${index}`,
    class: textClassName,
    style: { position: "relative", display: "inline-block", whiteSpace: "pre" } as StyleObject,
    $: [
      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, delay: index * staggerMs, easing: EASE_OUT_EXPO },
      }),
    ],
  };
}

function wordLayer(entry: WordEntry, staggerMs: number, transitionDurationMs: number, textClassName?: string): DomphyElement<"span"> {
  const characters = Array.from(entry.word);
  return {
    span: characters.map((char, index) => characterLayer(char, index, entry.key, staggerMs, transitionDurationMs, textClassName)),
    _key: entry.key,
    style: {
      position: "absolute",
      inset: 0,
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      whiteSpace: "nowrap",
    } as StyleObject,
  };
}

/**
 * An inline word-flip badge: a small bordered pill cycles through a list of
 * words, smoothly resizing to hug each new word while its letters fade/slide
 * in and out with a per-character stagger, sitting inline inside a short
 * headline sentence. Call with no arguments for a working demo.
 */
function containerTextFlip(props: ContainerTextFlipProps = {}): DomphyElement<"p"> {
  const words = props.words && props.words.length > 0 ? props.words : DEFAULT_WORDS;
  const holdIntervalMs = Math.max(200, props.interval ?? 3000);
  const transitionDurationMs = Math.max(50, props.animationDuration ?? 700);
  const startIndex = (((props.startIndex ?? 0) % words.length) + words.length) % words.length;
  const badgeColor = props.badgeColor ?? "neutral";
  const staggerMs = transitionDurationMs * PER_CHARACTER_STAGGER_RATIO;

  const instanceId = ++containerTextFlipInstanceCounter;
  let wordIndex = startIndex;
  let insertCount = 0;

  const wordEntries = toState<WordEntry[]>([{ key: `word-${instanceId}-0`, word: words[wordIndex] }]);
  const badgeWidth = toState<MotionKeyframe>({});

  let measureWordWidth: ((word: string) => number) | null = null;
  let horizontalPaddingPx = 0;

  function targetWidthPx(word: string): number {
    const measuredWidth = measureWordWidth ? measureWordWidth(word) : word.length * 9;
    return Math.ceil(measuredWidth + horizontalPaddingPx * 2);
  }

  const advance = () => {
    if (words.length <= 1) return;
    wordIndex = (wordIndex + 1) % words.length;
    insertCount += 1;
    const nextWord = words[wordIndex];
    wordEntries.set([{ key: `word-${instanceId}-${insertCount}`, word: nextWord }]);
    badgeWidth.set({ width: `${targetWidthPx(nextWord)}px` });
  };

  const badgeElement: DomphyElement<"span"> = {
    span: [
      {
        span: (listener: Listener) => wordEntries.get(listener).map((entry) => wordLayer(entry, staggerMs, transitionDurationMs, props.textClassName)),
        style: { position: "relative", display: "inline-block", minHeight: "1.15em", minWidth: "1ch" } as StyleObject,
      },
    ],
    class: props.className,
    // Edge-anchored dataTone chip surface — a small elevated pill, distinct
    // from (but close to) the page background, this package's standard
    // convention for this shape (see `layoutTextFlip.ts`'s own badge).
    dataTone: "shift-2",
    style: {
      position: "relative",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      overflow: "hidden",
      verticalAlign: "middle",
      borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * HORIZONTAL_PADDING_UNITS),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3", badgeColor)}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) => `0 ${themeSpacing(1)} ${themeSpacing(2)} ${themeColor(listener, "shift-3", badgeColor)}`,
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", badgeColor),
      color: (listener: Listener) => themeColor(listener, "shift-11", badgeColor),
      ...(props.style ?? {}),
    } as StyleObject,
    $: [motion({ animate: badgeWidth, transition: { duration: transitionDurationMs, easing: EASE_OUT_EXPO } })],
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined") return;
      const badgeDomElement = node.domElement as HTMLElement;
      const computedStyle = window.getComputedStyle(badgeDomElement);
      horizontalPaddingPx = (parseFloat(computedStyle.paddingLeft) || 0) + (parseFloat(computedStyle.paddingRight) || 0);

      const measureCanvas = document.createElement("canvas");
      const context = measureCanvas.getContext("2d");
      if (context) {
        context.font = `${computedStyle.fontWeight || "700"} ${computedStyle.fontSize || "16px"} ${computedStyle.fontFamily || "sans-serif"}`;
        measureWordWidth = (word: string) => context.measureText(word).width;
      }

      // Size the first word directly (no animation) so there is no layout
      // jump before the first scheduled swap.
      badgeDomElement.style.width = `${targetWidthPx(words[wordIndex])}px`;
    },
    _onRemove: () => {
      measureWordWidth = null;
    },
  };

  return {
    p: ["Ship your product ", badgeElement, "."],
    $: [paragraph()],
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || words.length <= 1) return;
      const timer = window.setInterval(advance, holdIntervalMs);
      node.addHook("Remove", () => window.clearInterval(timer));
    },
  } as DomphyElement<"p">;
}

export { containerTextFlip };

← Back to Aceternity UI catalog