Copy with Text Morph

Copy Button with animated text using Motion-Primitives.

ReactMotionClientBlocks

Preview

Preparing...

Introduction

In this block, we'll create a copy button with animated text using Motion-Primitives by Julien Thibeaut. The button will morph between "Copy" and "Copied" when clicked, providing visual feedback to the user.

Installation

shadcn/ui

shadcn/ui Command
pnpm dlx shadcn@latest add https://code-blocks.pheralb.dev/r/block-copy-text-morph.json

Manual

  1. Install motion:
Motion
pnpm i motion
  1. Create the basic Code Block structure:
reactCode Block

The main structure of the Code Block component with header and content areas.

  1. Create the CodeblockShiki client component:
reactCode Block Client with Shiki

Create a client-side Code Block component using Shiki for syntax highlighting.

  1. Finally, create the CopyTextMorph component:
.tsx
"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;
  size?: "xs" | "sm";
}

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,
  size = "sm",
  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-neutral-700 hover:text-neutral-950 dark:text-neutral-300 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",
        size === "xs" && "text-xs",
        size === "sm" && "text-sm",
        isCopied && "text-neutral-950 dark:text-neutral-50",
        className,
      )}
      onClick={handleCopy}
      {...props}
    >
      <TextMorph>{isCopied ? `Copied` : `Copy`}</TextMorph>
    </button>
  );
};

export { CopyTextMorph };

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> component:

.tsx
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;

Props

React Props

TextMorph

PropTypeRequired
asElementType<any, keyof IntrinsicElements>No
classNamestringNo
styleCSSPropertiesNo
variantsVariantsNo
transitionTransitionNo

CopyTextMorph

PropTypeRequired
contentstringYes
size"xs" | "sm"No
Included: DOMAttributes