Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Morphing Text component and demo #294

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ export const docsConfig: DocsConfig = {
items: [],
label: "",
},
{
title: "Morphing Text",
href: `/docs/components/morphing-text`,
items: [],
label: "New",
}
],
},
{
Expand Down
78 changes: 78 additions & 0 deletions content/docs/components/morphing-text.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
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 magicui-cli add morphing-text
```

</TabsContent>

<TabsContent value="manual">

<Steps>

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

<ComponentSource name="morphing-text" />

<Step>Update the import paths to match your project setup.</Step>

</Steps>

</TabsContent>

</Tabs>

<ComponentSource name="morphing-text" />

## Props

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

## Usage Example

```tsx
import MorphingText from "../magicui/morphing-text";

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

export default function MorphingTextDemo() {
return <MorphingText texts={texts} />;
}
```

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/)
17 changes: 17 additions & 0 deletions registry/components/example/morphing-text-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import MorphingText from "../magicui/morphing-text";

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

export default function MorphingTextDemo() {
return <MorphingText texts={texts} />;
}
139 changes: 139 additions & 0 deletions registry/components/magicui/morphing-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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 w-full inline-block inset-x-0 top-0 m-auto"
ref={text1Ref}
/>
<span
className="absolute w-full inline-block inset-x-0 top-0 m-auto"
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(
"[filter:url(#threshold)_blur(0.6px)] w-full text-[40pt] h-16 lg:text-[6rem] md:h-24 leading-none text-center relative font-sans font-bold max-w-screen-md mx-auto",
className,
)}
>
<Texts texts={texts} />
<SvgFilters />
</div>
);

export default MorphingText;
13 changes: 13 additions & 0 deletions registry/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ const ui: Registry = {
type: "components:magicui",
files: ["registry/components/magicui/iphone-15-pro.tsx"],
},
"morphing-text": {
name: "morphing-text",
type: "components:magicui",
files: ["registry/components/magicui/morphing-text.tsx"],
},
};

const example: Registry = {
Expand Down Expand Up @@ -995,6 +1000,14 @@ const example: Registry = {
() => import("@/registry/components/example/iphone-15-pro-demo-2"),
),
},
"morphing-text-demo": {
name: "morphing-text-demo",
type: "components:example",
files: ["registry/components/example/morphing-text-demo.tsx"],
component: React.lazy(
() => import("@/registry/components/example/morphing-text-demo"),
),
},
};
export const registry: Registry = {
...ui,
Expand Down
Loading