Skip to content
1 change: 1 addition & 0 deletions apps/website/src/app/api/docs/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function GET(request: Request) {
const docData = getDocument({
folder: folder,
document: document,
withGenerals: true,
});

if (!docData) {
Expand Down
136 changes: 136 additions & 0 deletions apps/website/src/components/code-block/blocks/copy-text-morph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import {
useMemo,
useId,
useEffect,
useState,
type ComponentProps,
} from "react";

import {
motion,
AnimatePresence,
type Transition,
type Variants,
} from "motion/react";

import { cn } from "@/utils/cn";
import { copyToClipboard } from "@/utils/copy";

interface CopyTextAnimatedProps extends ComponentProps<"button"> {
content: string;
iconSize?: number;
}

export type TextMorphProps = {
children: string;
as?: React.ElementType;
className?: string;
style?: React.CSSProperties;
variants?: Variants;
transition?: Transition;
};

export function TextMorph({
children,
as: Component = "p",
className,
style,
variants,
transition,
}: TextMorphProps) {
const uniqueId = useId();

const characters = useMemo(() => {
const charCounts: Record<string, number> = {};

return children.split("").map((char) => {
const lowerChar = char.toLowerCase();
charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;

return {
id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
label: char === " " ? "\u00A0" : char,
};
});
}, [children, uniqueId]);

const defaultVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};

const defaultTransition: Transition = {
type: "spring",
stiffness: 280,
damping: 18,
mass: 0.3,
};

return (
<Component className={cn(className)} aria-label={children} style={style}>
<AnimatePresence mode="popLayout" initial={false}>
{characters.map((character) => (
<motion.span
key={character.id}
layoutId={character.id}
className="inline-block"
aria-hidden="true"
initial="initial"
animate="animate"
exit="exit"
variants={variants || defaultVariants}
transition={transition || defaultTransition}
>
{character.label}
</motion.span>
))}
</AnimatePresence>
</Component>
);
}

const CopyTextMorph = ({
content,
className,
...props
}: CopyTextAnimatedProps) => {
const [isCopied, setIsCopied] = useState<boolean>(false);

useEffect(() => {
if (!isCopied) return;

const timeout = setTimeout(() => {
setIsCopied(false);
}, 2000);
return () => clearTimeout(timeout);
}, [isCopied]);

const handleCopy = async () => {
await copyToClipboard(content);
setIsCopied(true);
};

return (
<button
title="Copy to clipboard"
className={cn(
"cursor-pointer",
"transition-colors duration-200 ease-in-out",
"text-xs text-neutral-700 dark:text-neutral-300 hover:text-neutral-950 dark:hover:text-neutral-50",
"rounded-md bg-neutral-300/60 px-1.5 py-1 dark:bg-neutral-700/60",
"border border-transparent hover:border-neutral-400/60 dark:hover:border-neutral-600/60",
isCopied && "text-neutral-950 dark:text-neutral-50",
className,
)}
onClick={handleCopy}
{...props}
>
<TextMorph>{isCopied ? `Copied` : `Copy`}</TextMorph>
</button>
);
};

export { CopyTextMorph };
17 changes: 14 additions & 3 deletions apps/website/src/components/docs/show-categories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ import {
TagIcon,
} from "lucide-react";

import {
Shiki,
SugarHigh,
React,
RadixUI,
BaseUI,
Motion,
} from "@/components/ui/svgs";

import { cn } from "@/utils/cn";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { ExternalLink } from "@/components/ui/external-link";
import { Shiki, SugarHigh, React } from "@/components/ui/svgs";
import { RadixUI } from "../ui/svgs/radix-ui";
import { BaseUI } from "../ui/svgs/base-ui";

const categorySvgs = [
{
Expand Down Expand Up @@ -65,6 +71,11 @@ const categorySvgs = [
icon: BaseUI,
url: "https://base-ui.com/",
},
{
name: "Motion",
icon: Motion,
url: "https://motion.dev/",
},
{
name: "Blocks",
icon: BoxIcon,
Expand Down
4 changes: 4 additions & 0 deletions apps/website/src/components/docs/sidebar-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export const SidebarLinksData: SidebarLinks[] = [
title: "Multi Tabs",
href: "/docs/react/blocks/multi-tabs",
},
{
title: "Copy with Text Morph",
href: "/docs/react/blocks/copy-text-morph",
},
{
title: "Persist Package Manager",
href: "/docs/react/blocks/persist-package-manager",
Expand Down
1 change: 0 additions & 1 deletion apps/website/src/components/github-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import useSWR from "swr";

import { cn } from "@/utils/cn";
import { globals } from "@/globals";
import { StarIcon } from "lucide-react";

import { GitHub } from "@/components/ui/svgs/github";
import { buttonVariants } from "@/components/ui/button";
Expand Down
37 changes: 37 additions & 0 deletions apps/website/src/components/previews/copy-text-morph-example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
CodeBlock,
CodeBlockContent,
CodeBlockGroup,
CodeBlockHeader,
CodeBlockIcon,
} from "@/components/code-block/code-block";
import { CodeblockShiki } from "@/components/code-block/client/shiki";
import { CopyTextMorph } from "@/components/code-block/blocks/copy-text-morph";

const code = `const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
console.log("Text copied to clipboard");
} catch (err) {
console.error("Failed to copy text: ", err);
}
};`;

const CopyMorphExample = () => {
return (
<CodeBlock>
<CodeBlockHeader>
<CodeBlockGroup>
<CodeBlockIcon language="js" />
<span>Copy with Text Morph</span>
</CodeBlockGroup>
<CopyTextMorph content={code} />
</CodeBlockHeader>
<CodeBlockContent>
<CodeblockShiki language="ts" code={code} />
</CodeBlockContent>
</CodeBlock>
);
};

export default CopyMorphExample;
17 changes: 17 additions & 0 deletions apps/website/src/components/registry/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,23 @@ const Blocks: RegistryComponent[] = [
target: "src/components/code-block/blocks/inline-code.tsx",
},
},
{
title: "Blocks - Copy + Text Morph",
fileType: "tsx",
group: "blocks",
fileSource: `${codeblockComponent}/blocks/copy-text-morph.tsx`,
exampleFileSource: `${componentsFolder}/previews/copy-text-morph-example.tsx`,
reactComponent: lazy(
() => import("@/components/previews/copy-text-morph-example"),
),
shadcnRegistry: {
name: "block-copy-text-morph",
type: "registry:block",
dependencies: ["motion"],
registryDependencies: ["shiki-highlighter", "code-block", "client-shiki"],
target: "src/components/code-block/blocks/copy-text-morph.tsx",
},
},
{
title: "Blocks - Select Package Manager",
fileType: "tsx",
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const Sidebar = ({ children, position }: SidebarProps) => {
return (
<aside
className={cn(
"fixed w-56",
"fixed w-60",
"h-[calc(100vh-3.5rem)]",
"hidden md:block",
"overflow-x-hidden overflow-y-auto",
Expand Down Expand Up @@ -72,7 +72,7 @@ const SidebarPageContent = ({
className,
}: SidebarPageContentProps) => {
return (
<main className={cn("ml-0 md:ml-56 lg:ml-56 xl:mx-56", className)}>
<main className={cn("ml-0 md:ml-60 lg:ml-60 xl:mx-60", className)}>
{children}
</main>
);
Expand Down
3 changes: 3 additions & 0 deletions apps/website/src/components/ui/svgs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ export * from "./shiki";
export * from "./sugar-high";
export * from "./twitter";
export * from "./vite";
export * from "./radix-ui";
export * from "./base-ui";
export * from "./motion";
12 changes: 12 additions & 0 deletions apps/website/src/components/ui/svgs/motion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";

const Motion = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1103 386">
<path
className="fill-yellow-500 dark:fill-yellow-300"
d="M416.473 0 198.54 385.66H0L170.17 84.522C196.549 37.842 262.377 0 317.203 0Zm486.875 96.415c0-53.249 44.444-96.415 99.27-96.415 54.826 0 99.27 43.166 99.27 96.415 0 53.248-44.444 96.415-99.27 96.415-54.826 0-99.27-43.167-99.27-96.415ZM453.699 0h198.54L434.306 385.66h-198.54Zm234.492 0h198.542L716.56 301.138c-26.378 46.68-92.207 84.522-147.032 84.522h-99.27Z"
/>
</svg>
);

export { Motion };
2 changes: 1 addition & 1 deletion apps/website/src/components/ui/svgs/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const React = (props: SVGProps<SVGSVGElement>) => (
<g fill="none" fillRule="evenodd">
<g
transform="translate(-227, -256)"
fill="currentColor"
fillRule="nonzero"
className="fill-blue-600 dark:fill-blue-400"
>
<g transform="translate(227, 256)">
<path
Expand Down
76 changes: 76 additions & 0 deletions apps/website/src/docs/react/blocks/copy-text-morph.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
title: Copy with Text Morph
description: Copy Button with animated text using Motion-Primitives.
category: [React, Motion, Shiki, Blocks]
---

<ComponentPreview component="block-copy-text-morph" />

## Introduction

In this block, we'll create a copy button that not only copies text to the clipboard but also provides animated feedback using [Motion-Primitives](https://motion-primitives.com/) created by [ibelick](https://github.com/ibelick).

## Installation

### shadcn/ui

<CopyShadcnCommand name="block-copy-text-morph" />

### Manual

1. Install [motion](https://motion.dev/):

<CodeBlockSelectPkg type="install" title="Motion" command="motion" />

2. Create the basic Code Block structure:

<DocCard folder="react" document="code-block" />

3. Create the `CodeblockShiki` client component:

<DocCard folder="react" document="code-block-client-shiki" />

4. Finally, create the `CopyTextAnimated` component:

<ShowSource component="block-copy-text-morph" />

## Usage

```tsx
import { CopyTextMorph } from "@/components/code-block/blocks/copy-text-morph";

const Example = () => {
return <CopyTextMorph content="Text to be copied" />;
};

export default Example;
```

Or with [`<CodeBlock>`](/docs/react/code-block) component:

```tsx {6,16}
import {
CodeBlock,
CodeBlockHeader,
CodeBlockContent,
} from "@/components/code-block/code-block";
import { CopyTextMorph } from "@/components/code-block/blocks/copy-text-morph";

const code = `const greet = () => {
console.log("Hello, World!");
};`;

const Example = () => {
return (
<CodeBlock>
<CodeBlockHeader>
<CopyTextMorph content={code} />
</CodeBlockHeader>
<CodeBlockContent>
<CodeblockShiki language="ts" code={code} />
</CodeBlockContent>
</CodeBlock>
);
};
export default Example;
```
Loading