Components

Gooey Text Morphing

A smooth, gooey text morphing animation between multiple strings.

Last updated on

Edit on GitHub

Gooey Text Morphing

"use client";
 
import { GooeyText } from "@/components/ui/gooey-text-morphing";
 
export function GooeyTextMorphingDemo() {
  const texts = ["Why", "is", "it", "so", "satisfying", "to", "watch?"];
 
  return (
    <div className="flex h-[300px] w-full items-center justify-center overflow-hidden rounded-xl border bg-background">
      <GooeyText
        texts={texts}
        morphTime={1}
        cooldownTime={0.5}
        textClassName="font-bold tracking-tighter"
      />
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/gooey-text-morphing"

Manual

Copy and paste the following code into your project. component/ui/gooey-text-morphing.tsx

"use client";
 
import * as React from "react";
import { cn } from "@/lib/utils";
 
interface GooeyTextProps {
  texts: string[];
  morphTime?: number;
  cooldownTime?: number;
  className?: string;
  textClassName?: string;
}
 
export function GooeyText({
  texts,
  morphTime = 1,
  cooldownTime = 0.25,
  className,
  textClassName,
}: GooeyTextProps) {
  const text1Ref = React.useRef<HTMLSpanElement>(null);
  const text2Ref = React.useRef<HTMLSpanElement>(null);
 
  React.useEffect(() => {
    let textIndex = texts.length - 1;
    let time = new Date();
    let morph = 0;
    let cooldown = cooldownTime;
 
    const setMorph = (fraction: number) => {
      if (text1Ref.current && text2Ref.current) {
        text2Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
        text2Ref.current.style.opacity = `${fraction ** 0.4 * 100}%`;
 
        fraction = 1 - fraction;
        text1Ref.current.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
        text1Ref.current.style.opacity = `${fraction ** 0.4 * 100}%`;
      }
    };
 
    const doCooldown = () => {
      morph = 0;
      if (text1Ref.current && text2Ref.current) {
        text2Ref.current.style.filter = "";
        text2Ref.current.style.opacity = "100%";
        text1Ref.current.style.filter = "";
        text1Ref.current.style.opacity = "0%";
      }
    };
 
    const doMorph = () => {
      morph -= cooldown;
      cooldown = 0;
      let fraction = morph / morphTime;
 
      if (fraction > 1) {
        cooldown = cooldownTime;
        fraction = 1;
      }
 
      setMorph(fraction);
    };
 
    function animate() {
      requestAnimationFrame(animate);
      const newTime = new Date();
      const shouldIncrementIndex = cooldown > 0;
      const dt = (newTime.getTime() - time.getTime()) / 1000;
      time = newTime;
 
      cooldown -= dt;
 
      if (cooldown <= 0) {
        if (shouldIncrementIndex) {
          textIndex = (textIndex + 1) % texts.length;
          if (text1Ref.current && text2Ref.current) {
            text1Ref.current.textContent =
              texts[textIndex % texts.length] ?? "";
            text2Ref.current.textContent =
              texts[(textIndex + 1) % texts.length] ?? "";
          }
        }
        doMorph();
      } else {
        doCooldown();
      }
    }
 
    animate();
 
    return () => {
      // Cleanup function if needed
    };
  }, [texts, morphTime, cooldownTime]);
 
  return (
    <div className={cn("relative", className)}>
      <svg className="absolute h-0 w-0" aria-hidden="true" focusable="false">
        <defs>
          <filter id="threshold">
            <feColorMatrix
              in="SourceGraphic"
              type="matrix"
              values="1 0 0 0 0
                      0 1 0 0 0
                      0 0 1 0 0
                      0 0 0 255 -140"
            />
          </filter>
        </defs>
      </svg>
 
      <div
        className="flex items-center justify-center"
        style={{ filter: "url(#threshold)" }}
      >
        <span
          ref={text1Ref}
          className={cn(
            "absolute inline-block select-none text-center text-6xl md:text-[60pt]",
            "text-foreground",
            textClassName,
          )}
        />
        <span
          ref={text2Ref}
          className={cn(
            "absolute inline-block select-none text-center text-6xl md:text-[60pt]",
            "text-foreground",
            textClassName,
          )}
        />
      </div>
    </div>
  );
}

Usage

import { GooeyText } from "@/components/ui/gooey-text-morphing";

function MyComponent() {
  const texts = ["Hello", "World", "Gooey", "Morph"];
  
  return (
    <GooeyText 
      texts={texts} 
      morphTime={1} 
      cooldownTime={0.5} 
    />
  );
}

API Reference

Prop

Type

Notes

  • The component uses SVG filters to achieve the "gooey" effect.
  • The texts array can contain any number of strings.
  • morphTime controls the duration of the transition between words.
  • cooldownTime controls how long each word stays visible before morphing again.
  • Ensure the container has enough height to accommodate the text size.

Credits

Inspired by Victor Welander.

How is this guide?

On this page