Domphy

morphingText

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

Implementation notes

Full behavior implemented: a hidden SVG filter (feGaussianBlur -> feColorMatrix with a steep alpha-contrast matrix) applied statically to a container wrapping absolutely-stacked phrase layers; on each interval tick the reactive single-item phrase list is replaced in one set() call so the framework's reconciler mounts the new phrase (motion() enter) while the old one's _onBeforeRemove plays its exit concurrently — the goo filter fuses their overlapping soft edges during that overlap and re-sharpens a single resting phrase. The spec's optional third 'plain non-filtered duplicate layer on top for crisp readability' was intentionally omitted: the same contrast-matrix thresholding that produces the goo effect during overlap is what keeps a single settled phrase sharp, so the extra layer is redundant for this implementation. jsdom has no Web Animations API, so motion()'s enter/exit become synchronous no-ops in tests (verified structurally); real browsers get the actual opacity tween.

Status: ported · Reference: Magic UI original

// magicui "Morphing Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A single
// centered line of display text that liquid-morphs from one phrase to the
// next on a fixed timer, using the classic SVG "goo" filter technique
// (heavy Gaussian blur re-thresholded by a contrast-boosting color matrix)
// so two overlapping soft-edged phrases visually fuse and separate like
// liquid instead of cross-dissolving. See Codrops' "Morphing Gooey Text
// Hover Effect" and similar tutorials for the general, framework-agnostic
// technique this is built from.
//
// Only the phrase layers' opacity is actually animated (via `motion()`'s
// enter/exit, driven by a reactive keyed list); the blur+matrix filter,
// applied once and left static on the shared container, is what turns a
// plain crossfade of two overlapping shapes into a gooey merge — and turns
// a single resting shape back into crisp, readable text once settled.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, motion } from "@domphy/ui";

export interface MorphingTextProps {
  /** Phrases cycled through in order, looping back to the first. Defaults to a short demo sequence. */
  phrases?: string[];
  /** Milliseconds each phrase is shown before morphing to the next. Defaults to 2500. */
  interval?: number;
  /** Milliseconds the morph (opacity cross-animation) itself takes. Defaults to 600. */
  transitionDuration?: number;
  /** CSS easing for the morph. Defaults to "ease-in-out". */
  easing?: string;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

let morphingTextInstanceCounter = 0;

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

/** Hidden SVG holding the shared "goo" filter definition — blur then re-threshold via a steep alpha contrast matrix. */
function gooFilterDefs(filterId: string): DomphyElement<"svg"> {
  return {
    svg: [
      {
        defs: [
          {
            filter: [
              {
                feGaussianBlur: null,
                in: "SourceGraphic",
                stdDeviation: "8",
                result: "blurred",
              },
              {
                feColorMatrix: null,
                in: "blurred",
                mode: "matrix",
                values: "1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9",
                result: "goo",
              },
            ],
            id: filterId,
          },
        ],
      },
    ],
    ariaHidden: "true",
    style: { position: "absolute", width: 0, height: 0, overflow: "hidden" },
  } as DomphyElement<"svg">;
}

function phraseLayer(
  entry: PhraseEntry,
  transitionDuration: number,
  easing: string,
): DomphyElement<"div"> {
  return {
    div: [
      {
        h2: entry.text,
        $: [heading()],
        style: { margin: 0, textAlign: "center" },
      },
    ],
    _key: entry.key,
    style: {
      position: "absolute",
      inset: 0,
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
    },
    $: [
      motion({
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: transitionDuration, easing },
      }),
    ],
  };
}

/**
 * A single line of display text that automatically, endlessly morphs
 * between phrases with a gooey blur-and-sharpen transition instead of an
 * instant change or plain crossfade. No interaction required. Call with no
 * arguments for a working demo cycling through a short phrase list.
 */
function morphingText(props: MorphingTextProps = {}): DomphyElement<"div"> {
  const phrases = props.phrases ?? ["Build", "Ship", "Scale", "Repeat"];
  const interval = props.interval ?? 2500;
  const transitionDuration = props.transitionDuration ?? 600;
  const easing = props.easing ?? "ease-in-out";

  const instanceId = ++morphingTextInstanceCounter;
  const filterId = `domphy-morphing-text-goo-${instanceId}`;

  const layers = toState<PhraseEntry[]>(
    phrases.length > 0 ? [{ key: "phrase-0", text: phrases[0] }] : [],
  );
  let phraseIndex = 0;
  let insertCount = 0;

  const advance = () => {
    if (phrases.length <= 1) return;
    phraseIndex = (phraseIndex + 1) % phrases.length;
    insertCount += 1;
    // Replacing the whole (single-item) array in one `set()` call lets the
    // reconciler run both halves of the crossfade at once: the new key
    // mounts immediately (`motion()`'s enter), while the old key's
    // `_onBeforeRemove` plays its exit and only unmounts once finished —
    // both layers are absolutely stacked, so they visibly overlap while the
    // shared goo filter fuses their soft edges together.
    layers.set([{ key: `phrase-${insertCount}`, text: phrases[phraseIndex] }]);
  };

  return {
    div: [
      gooFilterDefs(filterId),
      {
        div: (listener) =>
          layers
            .get(listener)
            .map((entry) => phraseLayer(entry, transitionDuration, easing)),
        style: {
          position: "relative",
          width: "100%",
          height: "100%",
          filter: `url(#${filterId})`,
        },
      },
    ],
    style: {
      position: "relative",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      width: "100%",
      minHeight: "1.5em",
      ...(props.style ?? {}),
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || phrases.length <= 1) return;
      const timer = setInterval(advance, interval);
      node.addHook("Remove", () => clearInterval(timer));
    },
  };
}

export { morphingText };

← Back to Magic UI catalog