Loading component…
Components /Morph Text
Blur-morph word rotation effect
"use client";
import React, { useId } from "react";
import { cn } from "@/lib/utils";
// ─── Types ─────────────────────────────────────────────────────────────────
export interface MorphTextProps {
/**
* Array of words / phrases to cycle through.
* @default ["CREATE", "DESIGN", "DEVELOP"]
*/
words?: string[];
/**
* Duration (ms) each word is displayed before transitioning.
* @default 3000
*/
interval?: number;
/**
* Optional subtext rendered beneath the morphing word.
*/
subtext?: string;
/**
* Font size passed as a CSS value (e.g. "clamp(3rem, 15vw, 10rem)").
* Defaults to a fluid clamp that scales with the viewport.
*/
fontSize?: string;
/**
* Font family. Defaults to `"Space Grotesk", sans-serif`.
*/
fontFamily?: string;
/** Extra CSS classes on the root wrapper. */
className?: string;
/** Extra CSS classes on the morphing text container. */
textClassName?: string;
/** Extra CSS classes on the subtext element. */
subtextClassName?: string;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function MorphText({
words = ["CREATE", "DESIGN", "DEVELOP"],
interval = 3000,
subtext,
fontSize = "clamp(3rem, 15vw, 10rem)",
fontFamily = '"Space Grotesk", sans-serif',
className,
textClassName,
subtextClassName,
}: MorphTextProps) {
// Unique ID so multiple instances don't share filter IDs
const uid = useId().replace(/:/g, "");
const filterId = `morph-threshold-${uid}`;
const totalDuration = (interval / 1000) * words.length; // seconds
const wordDuration = interval / 1000;
// Build per-word keyframe + delay styles
const wordStyles = words.map((_, i) => ({
animationDelay: `${i * wordDuration}s`,
animationDuration: `${totalDuration}s`,
}));
return (
<div className={cn("morph-text-root relative flex flex-col items-center", className)}>
{/* ── Threshold SVG filter (hidden) ─────────────────────────── */}
<svg
aria-hidden="true"
focusable="false"
style={{ position: "absolute", width: 0, height: 0, pointerEvents: "none" }}
>
<defs>
<filter id={filterId}>
<feColorMatrix
in="SourceGraphic"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 25 -9"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* ── Morphing word container ────────────────────────────────── */}
<div
className={cn("morph-text-container relative select-none", textClassName)}
style={{
fontSize,
fontWeight: 700,
filter: `url(#${filterId})`,
fontFamily,
}}
>
{/* word rotator */}
<div
className="morph-word-rotator relative flex items-center justify-center"
style={{ height: "1.2em", minWidth: "14ch" }}
>
{words.map((word, i) => (
<span
key={`${word}-${i}`}
className="morph-word absolute"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: 0,
whiteSpace: "nowrap",
animationName: "morph-word-rotate",
animationTimingFunction: "ease-in-out",
animationIterationCount: "infinite",
animationFillMode: "both",
...wordStyles[i],
}}
>
{word}
</span>
))}
</div>
</div>
{/* ── Optional subtext ──────────────────────────────────────── */}
{subtext && (
<p
className={cn(
"morph-subtext mt-8 uppercase tracking-[0.2em] text-[#888]",
subtextClassName
)}
style={{
fontSize: "1.2rem",
opacity: 0,
animation: "morph-fade-up 1s ease-out 1s forwards",
fontFamily,
}}
>
{subtext}
</p>
)}
{/* ── Scoped keyframes ──────────────────────────────────────── */}
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;500;700&display=swap');
@keyframes morph-word-rotate {
0% {
opacity: 0;
filter: blur(20px);
transform: translate(-50%, -50%) scale(0.8);
}
5% {
opacity: 0.5;
filter: blur(10px);
}
15%, 35% {
opacity: 1;
filter: blur(0px);
transform: translate(-50%, -50%) scale(1);
}
45% {
opacity: 0.5;
filter: blur(10px);
}
50%, 100% {
opacity: 0;
filter: blur(20px);
transform: translate(-50%, -50%) scale(1.2);
}
}
@keyframes morph-fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}
export default MorphText;
Run the following command
npx shadcn@latest add https://vengeance-ui-v2.vercel.app/r/morph-text.json1import { MorphText } from "@/components/ui/morph-text"23// Default — cycles CREATE / DESIGN / DEVELOP4export function MyHero() {5 return (6 <MorphText subtext="The Art of Code" />7 )8}910// Custom words and interval11export function CustomMorph() {12 return (13 <MorphText14 words={["INNOVATE", "BUILD", "SHIP"]}15 interval={2500}16 subtext="Move fast. Break things."17 fontSize="clamp(2rem, 10vw, 8rem)"18 />19 )20}
| Prop Name | Type | Default | Description |
|---|---|---|---|
| words | string[] | ["CREATE", "DESIGN", "DEVELOP"] | Array of words or phrases to cycle through. |
| interval | number | 3000 | Duration in ms each word is shown before transitioning. |
| subtext | string | - | Optional subtext rendered beneath the morphing word. |
| fontSize | string | "clamp(3rem, 15vw, 10rem)" | CSS font-size value for the morphing text. |
| fontFamily | string | "Space Grotesk", sans-serif | Font family applied to both the morph text and subtext. |
| className | string | - | Extra classes on the root wrapper. |
| textClassName | string | - | Extra classes on the morphing text container. |
| subtextClassName | string | - | Extra classes on the subtext element. |