Skip to content

Commit

Permalink
feat: add Morphing Text component and demo (#294)
Browse files Browse the repository at this point in the history
* feat: add Morphing Text component and demos

* feat: update Morphing Text component title

* fix: lint fix

---------

Co-authored-by: Arghya Das <arghyadasproject@gmail.com>
  • Loading branch information
luis-codex and itsarghyadas authored Dec 20, 2024
1 parent f69220c commit 4e527dd
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 0 deletions.
26 changes: 26 additions & 0 deletions __registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: [],
},
"morphing-text": {
name: "morphing-text",
type: "registry:ui",
registryDependencies: undefined,
files: ["registry/default/magicui/morphing-text.tsx"],
component: React.lazy(
() => import("@/registry/default/magicui/morphing-text.tsx"),
),
source: "",
category: "undefined",
subcategory: "undefined",
chunks: [],
},
"scroll-progress": {
name: "scroll-progress",
type: "registry:ui",
Expand Down Expand Up @@ -776,6 +789,19 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: [],
},
"morphing-text-demo": {
name: "morphing-text-demo",
type: "registry:example",
registryDependencies: ["morphing-text"],
files: ["registry/default/example/morphing-text-demo.tsx"],
component: React.lazy(
() => import("@/registry/default/example/morphing-text-demo.tsx"),
),
source: "",
category: "undefined",
subcategory: "undefined",
chunks: [],
},
"scroll-progress-demo": {
name: "scroll-progress-demo",
type: "registry:example",
Expand Down
6 changes: 6 additions & 0 deletions config/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,12 @@ export const docsConfig: DocsConfig = {
items: [],
label: "",
},
{
title: "Morphing Text",
href: `/docs/components/morphing-text`,
items: [],
label: "New",
}
],
},
{
Expand Down
52 changes: 52 additions & 0 deletions content/docs/components/morphing-text.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
title: Morphing Text
date: 2024-09-02
description: A dynamic text morphing component for Magic UI.
author: magicui
published: true
---

<ComponentPreview name="morphing-text-demo" />

## Installation

<Tabs defaultValue="cli">

<TabsList>
<TabsTrigger value="cli">CLI</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">

```bash
npx shadcn@latest add "https://magicui.design/r/morphing-text"
```

</TabsContent>

<TabsContent value="manual">

<Steps>

<Step>Copy and paste the following code into your project.</Step>

<ComponentSource name="morphing-text" />

</Steps>

</TabsContent>

</Tabs>

## Props

| Prop | Type | Description | Default |
| ----------- | ---------- | ------------------------------------ | ------- |
| `texts` | `string[]` | Array of texts to morph between | `[]` |
| `className` | `string?` | Additional classes for the container | `""` |

This `MorphingText` component dynamically transitions between an array of text strings, creating a smooth, engaging visual effect.

## Credits

- Credit to [@luis-code](https://luis-code.vercel.app/)
13 changes: 13 additions & 0 deletions public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@
}
]
},
{
"name": "morphing-text",
"type": "registry:ui",
"dependencies": [
"framer-motion"
],
"files": [
{
"path": "magicui/morphing-text.tsx",
"type": "registry:ui"
}
]
},
{
"name": "scroll-progress",
"type": "registry:ui",
Expand Down
15 changes: 15 additions & 0 deletions public/r/styles/default/morphing-text.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "morphing-text",
"type": "registry:ui",
"dependencies": [
"framer-motion"
],
"files": [
{
"path": "magicui/morphing-text.tsx",
"content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst morphTime = 1.5;\nconst cooldownTime = 0.5;\n\nconst useMorphingText = (texts: string[]) => {\n const textIndexRef = useRef(0);\n const morphRef = useRef(0);\n const cooldownRef = useRef(0);\n const timeRef = useRef(new Date());\n\n const text1Ref = useRef<HTMLSpanElement>(null);\n const text2Ref = useRef<HTMLSpanElement>(null);\n\n const setStyles = useCallback(\n (fraction: number) => {\n const [current1, current2] = [text1Ref.current, text2Ref.current];\n if (!current1 || !current2) return;\n\n current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;\n current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;\n\n const invertedFraction = 1 - fraction;\n current1.style.filter = `blur(${Math.min(8 / invertedFraction - 8, 100)}px)`;\n current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`;\n\n current1.textContent = texts[textIndexRef.current % texts.length];\n current2.textContent = texts[(textIndexRef.current + 1) % texts.length];\n },\n [texts]\n );\n\n const doMorph = useCallback(() => {\n morphRef.current -= cooldownRef.current;\n cooldownRef.current = 0;\n\n let fraction = morphRef.current / morphTime;\n\n if (fraction > 1) {\n cooldownRef.current = cooldownTime;\n fraction = 1;\n }\n\n setStyles(fraction);\n\n if (fraction === 1) {\n textIndexRef.current++;\n }\n }, [setStyles]);\n\n const doCooldown = useCallback(() => {\n morphRef.current = 0;\n const [current1, current2] = [text1Ref.current, text2Ref.current];\n if (current1 && current2) {\n current2.style.filter = \"none\";\n current2.style.opacity = \"100%\";\n current1.style.filter = \"none\";\n current1.style.opacity = \"0%\";\n }\n }, []);\n\n useEffect(() => {\n let animationFrameId: number;\n\n const animate = () => {\n animationFrameId = requestAnimationFrame(animate);\n\n const newTime = new Date();\n const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;\n timeRef.current = newTime;\n\n cooldownRef.current -= dt;\n\n if (cooldownRef.current <= 0) doMorph();\n else doCooldown();\n };\n\n animate();\n return () => {\n cancelAnimationFrame(animationFrameId);\n };\n }, [doMorph, doCooldown]);\n\n return { text1Ref, text2Ref };\n};\n\ninterface MorphingTextProps {\n className?: string;\n texts: string[];\n}\n\nconst Texts: React.FC<Pick<MorphingTextProps, \"texts\">> = ({ texts }) => {\n const { text1Ref, text2Ref } = useMorphingText(texts);\n return (\n <>\n <span\n className=\"absolute inset-x-0 top-0 m-auto inline-block w-full\"\n ref={text1Ref}\n />\n <span\n className=\"absolute inset-x-0 top-0 m-auto inline-block w-full\"\n ref={text2Ref}\n />\n </>\n );\n};\n\nconst SvgFilters: React.FC = () => (\n <svg id=\"filters\" className=\"hidden\" preserveAspectRatio=\"xMidYMid slice\">\n <defs>\n <filter id=\"threshold\">\n <feColorMatrix\n in=\"SourceGraphic\"\n type=\"matrix\"\n values=\"1 0 0 0 0\n 0 1 0 0 0\n 0 0 1 0 0\n 0 0 0 255 -140\"\n />\n </filter>\n </defs>\n </svg>\n);\n\nconst MorphingText: React.FC<MorphingTextProps> = ({ texts, className }) => (\n <div\n className={cn(\n \"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] font-bold leading-none [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]\",\n className\n )}\n >\n <Texts texts={texts} />\n <SvgFilters />\n </div>\n);\n\nexport default MorphingText;\n",
"type": "registry:ui",
"target": ""
}
]
}
17 changes: 17 additions & 0 deletions registry/default/example/morphing-text-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import MorphingText from "@/registry/default/magicui/morphing-text";

const texts = [
"Hello",
"Morphing",
"Text",
"Animation",
"React",
"Component",
"Smooth",
"Transition",
"Engaging",
];

export default function MorphingTextDemo() {
return <MorphingText texts={texts} />;
}
141 changes: 141 additions & 0 deletions registry/default/magicui/morphing-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client";

import { useCallback, useEffect, useRef } from "react";

import { cn } from "@/lib/utils";

const morphTime = 1.5;
const cooldownTime = 0.5;

const useMorphingText = (texts: string[]) => {
const textIndexRef = useRef(0);
const morphRef = useRef(0);
const cooldownRef = useRef(0);
const timeRef = useRef(new Date());

const text1Ref = useRef<HTMLSpanElement>(null);
const text2Ref = useRef<HTMLSpanElement>(null);

const setStyles = useCallback(
(fraction: number) => {
const [current1, current2] = [text1Ref.current, text2Ref.current];
if (!current1 || !current2) return;

current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;

const invertedFraction = 1 - fraction;
current1.style.filter = `blur(${Math.min(8 / invertedFraction - 8, 100)}px)`;
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`;

current1.textContent = texts[textIndexRef.current % texts.length];
current2.textContent = texts[(textIndexRef.current + 1) % texts.length];
},
[texts],
);

const doMorph = useCallback(() => {
morphRef.current -= cooldownRef.current;
cooldownRef.current = 0;

let fraction = morphRef.current / morphTime;

if (fraction > 1) {
cooldownRef.current = cooldownTime;
fraction = 1;
}

setStyles(fraction);

if (fraction === 1) {
textIndexRef.current++;
}
}, [setStyles]);

const doCooldown = useCallback(() => {
morphRef.current = 0;
const [current1, current2] = [text1Ref.current, text2Ref.current];
if (current1 && current2) {
current2.style.filter = "none";
current2.style.opacity = "100%";
current1.style.filter = "none";
current1.style.opacity = "0%";
}
}, []);

useEffect(() => {
let animationFrameId: number;

const animate = () => {
animationFrameId = requestAnimationFrame(animate);

const newTime = new Date();
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;
timeRef.current = newTime;

cooldownRef.current -= dt;

if (cooldownRef.current <= 0) doMorph();
else doCooldown();
};

animate();
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [doMorph, doCooldown]);

return { text1Ref, text2Ref };
};

interface MorphingTextProps {
className?: string;
texts: string[];
}

const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
const { text1Ref, text2Ref } = useMorphingText(texts);
return (
<>
<span
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
ref={text1Ref}
/>
<span
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
ref={text2Ref}
/>
</>
);
};

const SvgFilters: React.FC = () => (
<svg id="filters" className="hidden" preserveAspectRatio="xMidYMid slice">
<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>
);

const MorphingText: React.FC<MorphingTextProps> = ({ texts, className }) => (
<div
className={cn(
"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] font-bold leading-none [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
className,
)}
>
<Texts texts={texts} />
<SvgFilters />
</div>
);

export default MorphingText;
6 changes: 6 additions & 0 deletions registry/registry-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export const examples: Registry = [
registryDependencies: ["magic-card"],
files: ["example/magic-card-demo.tsx"],
},
{
name: "morphing-text-demo",
type: "registry:example",
registryDependencies: ["morphing-text"],
files: ["example/morphing-text-demo.tsx"],
},
{
name: "scroll-progress-demo",
type: "registry:example",
Expand Down
6 changes: 6 additions & 0 deletions registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export const ui: Registry = [
dependencies: ["framer-motion"],
files: ["magicui/magic-card.tsx"],
},
{
name: "morphing-text",
type: "registry:ui",
dependencies: ["framer-motion"],
files: ["magicui/morphing-text.tsx"],
},
{
name: "scroll-progress",
type: "registry:ui",
Expand Down

0 comments on commit 4e527dd

Please sign in to comment.