Playground

Card Clip Path Hover

May 2026

WebDesign&Development

WebDesign&Development

Craftingfast,accessibleinterfaceswithcleancodeandpurposefulmotionfromconcepttopixel-perfectexecution.

"use client";

import {cn} from "@/lib/cn";
import {animate, motion, useMotionValue, useTransform} from "motion/react";
import {useRef, useState} from "react";

// ─── Utils ────────────────────────────────────────────────────────────────────

const splitLines = (str: string): string[][] =>
    str.split("\n").map((line) => line.trim().split(/\s+/));

// ─── Constants ────────────────────────────────────────────────────────────────

const TITLE = "Web Design &\nDevelopment";
const DESCRIPTION =
    "Crafting fast, accessible interfaces \n with clean code and purposeful motion\n — from concept to pixel-perfect\n execution.";

// ─── Hook ─────────────────────────────────────────────────────────────────────

function useClipPathMouse(onLeave?: () => void) {
    const ref = useRef<HTMLDivElement>(null);
    const x = useMotionValue(50);
    const y = useMotionValue(50);
    const size = useMotionValue(0);
    const clipPath = useTransform(
        [size, x, y],
        ([s, cx, cy]) => `circle(${s}% at ${cx}% ${cy}%)`
    );

    const setPos = (e: React.MouseEvent) => {
        const rect = ref.current?.getBoundingClientRect();
        if (!rect) return;
        x.set(((e.clientX - rect.left) / rect.width) * 100);
        y.set(((e.clientY - rect.top) / rect.height) * 100);
    };

    return {
        ref,
        clipPath,
        onMouseMove: setPos,
        onMouseEnter: (e: React.MouseEvent) => {
            setPos(e);
            animate(size, 150, {duration: 0.3, ease: "easeOut"});
        },
        onMouseLeave: (e: React.MouseEvent) => {
            setPos(e);
            animate(size, 0, {duration: 0.2, ease: "easeIn"});
            onLeave?.();
        },
    };
}

// ─── AnimatedWords ────────────────────────────────────────────────────────────

type WordVariant = {opacity: number; x: number};
type StaggerTransition = {
    staggerChildren: number;
    staggerDirection: 1 | -1;
    delayChildren?: number;
};

type AnimatedWordsProps = {
    text: string;
    wordVisible?: WordVariant;
    wordHidden?: WordVariant;
    visibleTransition?: StaggerTransition;
    hiddenTransition?: StaggerTransition;
};

const AnimatedWords = ({
    text,
    wordVisible = {opacity: 1, x: 0},
    wordHidden = {opacity: 0, x: 12},
    visibleTransition = {
        staggerChildren: 0.06,
        staggerDirection: 1,
        delayChildren: 0.15,
    },
    hiddenTransition = {staggerChildren: 0.06, staggerDirection: -1},
}: AnimatedWordsProps) => (
    <>
        {splitLines(text).map((lineWords, lineIndex) => (
            <motion.span
                key={lineIndex}
                className="block"
                variants={{
                    visible: {transition: visibleTransition},
                    hidden: {transition: hiddenTransition},
                }}>
                {lineWords.map((word, wordIndex) => (
                    <motion.span
                        key={wordIndex}
                        className="mr-1 inline-block"
                        variants={{visible: wordVisible, hidden: wordHidden}}
                        transition={{ease: "easeOut", duration: 0.2}}>
                        {word}
                    </motion.span>
                ))}
            </motion.span>
        ))}
    </>
);

// ─── Icon ─────────────────────────────────────────────────────────────────────

const Icon = () => (
    <svg
        width={48}
        height={48}
        viewBox="0 0 24 24"
        fill="none"
        xmlns="http://www.w3.org/2000/svg">
        <g clipPath="url(#clip0_1034_4452)">
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M23.63 7.42001C23.51 6.25001 23.55 3.84001 22.73 3.19001C20.81 1.68001 16.59 2.08001 13.92 2.00001C10.44 1.85001 6.72999 2.14001 3.29999 2.24001C3.22107 2.25369 3.14952 2.2948 3.09794 2.35609C3.04637 2.41738 3.01809 2.49491 3.01809 2.57501C3.01809 2.65511 3.04637 2.73263 3.09794 2.79392C3.14952 2.85521 3.22107 2.89632 3.29999 2.91001C6.76999 2.86001 10.37 2.62001 13.87 2.82001C16.34 2.97001 20.52 2.68001 22.12 3.98001C22.52 4.31001 22.53 6.71001 22.61 7.56001C22.959 10.5485 22.9858 13.5658 22.69 16.56C22.6507 17.5735 22.4998 18.5796 22.24 19.56C19.82 20.96 2.55999 21.94 1.52999 20.11C1.14167 18.5957 0.923648 17.0427 0.879995 15.48C0.66445 12.829 0.704635 10.1633 0.999995 7.52001C1.07999 7.52001 8.33 7.85001 13.58 7.76001C15.91 7.76001 18.26 7.63001 20.34 7.55001C20.4275 7.55001 20.5115 7.51524 20.5733 7.45335C20.6352 7.39146 20.67 7.30753 20.67 7.22001C20.67 7.13248 20.6352 7.04855 20.5733 6.98666C20.5115 6.92477 20.4275 6.89001 20.34 6.89001C12.63 6.73001 7.44999 6.46001 1.03999 6.94001C1.14999 5.56001 1.51999 3.02001 1.41999 2.94001C1.36392 2.88504 1.28852 2.85425 1.20999 2.85425C1.13147 2.85425 1.05607 2.88504 0.999995 2.94001C0.629995 3.35001 0.709995 4.71001 0.639995 5.19001C0.569995 5.67001 0.499995 6.26 0.439995 6.79001C0.0437741 9.65185 -0.076779 12.5451 0.0799949 15.43C0.105815 17.1166 0.327297 18.7945 0.739995 20.43C2.2 23.13 17.98 21.89 21.68 20.86C23.13 20.46 23.37 20.06 23.55 18.91C24.1242 15.1034 24.1512 11.2342 23.63 7.42001Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M4.53999 5.25001C4.61981 5.08343 4.64249 4.89518 4.60453 4.7144C4.56657 4.53363 4.47008 4.37041 4.32999 4.25001C3.55999 3.84001 2.61999 4.64001 3.32999 5.35001C3.41425 5.42662 3.51338 5.48508 3.6212 5.52174C3.72903 5.5584 3.84324 5.57247 3.95674 5.56309C4.07023 5.55371 4.18059 5.52108 4.28093 5.46722C4.38127 5.41335 4.46945 5.33942 4.53999 5.25001Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M7.16999 5.00001C7.30999 4.86001 7.35999 4.16001 6.96999 4.00001C6.19999 3.59001 5.25999 4.39001 5.96999 5.09001C6.05275 5.16695 6.15051 5.22595 6.25714 5.26332C6.36377 5.30069 6.47698 5.31562 6.58965 5.30716C6.70233 5.29871 6.81204 5.26707 6.91191 5.21421C7.01178 5.16136 7.09964 5.08843 7.16999 5.00001Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M10.05 5.00001C10.1316 4.83379 10.1552 4.64507 10.1171 4.46387C10.0791 4.28267 9.98153 4.11939 9.84 4.00001C9.07 3.59001 8.13 4.39001 8.84 5.09001C8.92291 5.1686 9.02133 5.22899 9.12894 5.26732C9.23655 5.30565 9.35099 5.32107 9.46491 5.3126C9.57883 5.30412 9.68972 5.27194 9.79047 5.21811C9.89123 5.16428 9.97963 5.09 10.05 5.00001Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M5.76 13.73C5.76 13.81 5.99 16.2 6.07 16.29C6.11382 16.339 6.1699 16.3754 6.23247 16.3955C6.29503 16.4156 6.36183 16.4187 6.42599 16.4045C6.49014 16.3902 6.54935 16.3591 6.5975 16.3144C6.64566 16.2697 6.68104 16.2129 6.7 16.15C6.7 16.06 6.7 16.15 6.85 14.43C6.89669 13.6849 6.85979 12.9369 6.74 12.2C6.59371 11.7567 6.50629 11.2961 6.48 10.83C6.49705 10.7615 6.48943 10.6893 6.45847 10.6259C6.42752 10.5625 6.37521 10.512 6.31073 10.4833C6.24626 10.4547 6.17375 10.4497 6.10595 10.4692C6.03814 10.4887 5.97938 10.5315 5.94 10.59C5.73908 11.3493 5.67141 12.1376 5.74 12.92C4.46 12.54 4.62 13.05 4.61 12.5C4.58733 11.8533 4.52387 11.2087 4.42 10.57C4.26 10.4 4.06 10.4 3.91 10.71C3.66533 11.8518 3.52144 13.013 3.48 14.18C3.51994 14.9518 3.66115 15.715 3.9 16.45C4.16 16.85 4.6 16.67 4.6 16.35C4.6 16.26 4.44 16.26 4.6 14.49C4.66 13.31 4.37 14.19 5.76 13.73Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M10.79 11.18C11.11 11.11 11.2 10.9 11.18 10.81C11.18 10.26 7.85999 10.47 7.71999 10.57C7.57999 10.67 7.51999 10.75 7.53999 10.85C7.55999 10.95 7.53999 10.96 7.53999 11.01C8.03791 11.243 8.58026 11.3658 9.12999 11.37V12.19C8.97753 13.226 8.93396 14.275 8.99999 15.32C8.99999 15.52 8.90999 16.26 9.16999 16.48C9.23372 16.5435 9.32002 16.5792 9.40999 16.5792C9.49996 16.5792 9.58626 16.5435 9.64999 16.48C9.77515 16.1295 9.84597 15.7619 9.85999 15.39C10.126 14.3341 10.1736 13.235 9.99999 12.16C9.99999 12.03 10.05 11.46 9.89999 11.27C10.1981 11.2563 10.4952 11.2262 10.79 11.18Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M16.33 11C16.3019 10.9117 16.2562 10.83 16.1958 10.7598C16.1354 10.6895 16.0615 10.6321 15.9784 10.5911C15.8953 10.55 15.8049 10.5261 15.7124 10.5208C15.6199 10.5154 15.5272 10.5288 15.44 10.56C14.9343 10.7636 14.4996 11.1113 14.19 11.56C14.111 11.2584 13.9715 10.976 13.78 10.73C13.6673 10.6186 13.5297 10.5357 13.3785 10.4879C13.2274 10.4402 13.0671 10.429 12.9109 10.4554C12.7546 10.4818 12.6069 10.5449 12.4798 10.6396C12.3527 10.7343 12.25 10.8578 12.18 11C11.9608 11.6687 11.8429 12.3664 11.83 13.07C11.83 13.64 11.83 15.71 11.89 15.83C12.03 16.92 12.89 16.34 12.52 15.96C12.4962 14.6299 12.6237 13.3013 12.9 12C13.22 10.82 13.22 12.2 13.29 12.51C13.36 12.82 13.4 13.59 14.02 13.59C14.64 13.59 14.5 12.66 15.42 11.81C15.4701 12.706 15.4701 13.604 15.42 14.5C15.3832 15.1028 15.3832 15.7072 15.42 16.31C15.42 16.4002 15.4558 16.4867 15.5196 16.5504C15.5833 16.6142 15.6698 16.65 15.76 16.65C15.8502 16.65 15.9367 16.6142 16.0004 16.5504C16.0642 16.4867 16.1 16.4002 16.1 16.31C16.1741 15.7363 16.2108 15.1584 16.21 14.58C16.3778 13.3944 16.418 12.1942 16.33 11Z"
                fill="currentColor"
            />
            <path
                fillRule="evenodd"
                clipRule="evenodd"
                d="M17.77 10.8C17.72 10.96 17.47 13.8 17.46 14C17.448 14.587 17.5187 15.1727 17.67 15.74C18 16.74 18.88 16.81 19.96 16.56C20.3497 16.5436 20.7344 16.466 21.1 16.33C21.31 16.11 21.2 15.74 20.69 15.69C20.1458 15.7541 19.5976 15.7775 19.05 15.76C18.61 15.69 18.71 15.76 18.38 11.32C18.3903 11.1969 18.3903 11.0731 18.38 10.95C18.3898 10.9099 18.3917 10.8683 18.3855 10.8276C18.3792 10.7868 18.365 10.7477 18.3437 10.7124C18.3223 10.6771 18.2942 10.6463 18.261 10.6219C18.2278 10.5975 18.19 10.5798 18.15 10.57C18.1099 10.5601 18.0683 10.5583 18.0276 10.5645C17.9868 10.5707 17.9476 10.5849 17.9124 10.6063C17.8771 10.6277 17.8463 10.6558 17.8219 10.689C17.7975 10.7222 17.7798 10.7599 17.77 10.8Z"
                fill="currentColor"
            />
        </g>
        <defs>
            <clipPath id="clip0_1034_4452">
                <rect width={24} height={24} fill="white" />
            </clipPath>
        </defs>
    </svg>
);

// ─── CardContent ──────────────────────────────────────────────────────────────

const CardContent = ({
    className,
    isOpen,
}: {
    className?: string;
    isOpen?: boolean;
}) => (
    <div
        className={cn(
            "relative flex h-full flex-col justify-between p-4",
            className
        )}>
        <motion.div
            animate={isOpen ? "hidden" : "visible"}
            initial="visible"
            variants={{
                visible: {opacity: 1, x: 0},
                hidden: {opacity: 0, x: -10},
            }}
            transition={{
                ease: "easeOut",
                duration: 0.2,
                delay: isOpen ? 0 : 0.15,
            }}>
            <Icon />
        </motion.div>
        <div className="flex items-end justify-between">
            <motion.h3
                className="font-poppins text-xl leading-tight font-medium"
                animate={isOpen ? "hidden" : "visible"}
                initial="visible">
                <AnimatedWords text={TITLE} wordHidden={{opacity: 0, x: 12}} />
            </motion.h3>
        </div>
    </div>
);

// ─── CardOverlayContent ───────────────────────────────────────────────────────

const CardOverlayContent = ({isOpen}: {isOpen: boolean}) => (
    <div
        className={cn(
            "absolute inset-0 flex flex-col justify-between gap-3 p-4",
            !isOpen && "pointer-events-none"
        )}>
        <motion.p
            className="text-sm leading-snug font-medium text-[#2C2C2C] opacity-80"
            animate={isOpen ? "visible" : "hidden"}
            initial="hidden">
            <AnimatedWords
                text={DESCRIPTION}
                wordVisible={{opacity: 1, x: 0}}
                wordHidden={{opacity: 0, x: 8}}
                visibleTransition={{
                    staggerChildren: 0.04,
                    staggerDirection: 1,
                    delayChildren: 0.15,
                }}
                hiddenTransition={{staggerChildren: 0.02, staggerDirection: 1}}
            />
        </motion.p>
        <motion.button
            className="w-fit rounded border border-[#2C2C2C] px-3 py-1.5 text-xs text-[#2C2C2C] transition-colors hover:bg-[#2C2C2C] hover:text-[#FAEDCA]"
            animate={isOpen ? {opacity: 1, x: 0} : {opacity: 0, x: -16}}
            initial={{opacity: 0, x: -16}}
            transition={{
                ease: "easeOut",
                duration: 0.3,
                delay: isOpen ? 0.15 : 0,
            }}>
            Learn More
        </motion.button>
    </div>
);

// ─── ToggleButton ─────────────────────────────────────────────────────────────

const ToggleButton = ({
    isOpen,
    onClick,
}: {
    isOpen: boolean;
    onClick: () => void;
}) => (
    <motion.button
        onClick={onClick}
        className="absolute right-4 bottom-4 z-10 flex size-9 items-center justify-center rounded-full bg-[#454040] text-[#FAEDCA]/90"
        whileTap={{scale: 0.9}}
        transition={{type: "spring", stiffness: 400, damping: 15}}>
        <div className="size-6 rounded-full border border-[#FAEDCA]/90 p-0.5">
            <motion.svg
                viewBox="0 0 24 24"
                className="size-full"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                animate={{rotate: isOpen ? 45 : 0}}
                transition={{duration: 0.4, ease: "easeInOut"}}
                style={{transformOrigin: "center"}}>
                <line x1="12" y1="5" x2="12" y2="19" />
                <motion.line
                    x1="5"
                    y1="12"
                    x2="19"
                    y2="12"
                    animate={{
                        rotate: isOpen ? 180 : 0,
                        filter: ["blur(0px)", "blur(4px)", "blur(0px)"],
                    }}
                    transition={{duration: 0.4, ease: "easeInOut"}}
                    style={{transformOrigin: "12px 12px"}}
                />
            </motion.svg>
        </div>
    </motion.button>
);

// ─── CardClipPathHover ────────────────────────────────────────────────────────

const CardClipPathHover = () => {
    const [isOpen, setIsOpen] = useState(false);
    const {ref, clipPath, ...mouseHandlers} = useClipPathMouse(() =>
        setIsOpen(false)
    );

    return (
        <article className="grid min-h-96 place-items-center bg-white/5 sm:aspect-video sm:min-h-0">
            <div
                ref={ref}
                className="relative aspect-414/400 w-75 overflow-hidden rounded-md md:aspect-414/300 md:w-1/2"
                {...mouseHandlers}>
                <CardContent
                    isOpen={isOpen}
                    className="h-full bg-[#2C2C2C] text-[#FAEDCA]"
                />
                <motion.div className="absolute inset-0" style={{clipPath}}>
                    <div className="relative size-full">
                        <CardContent
                            className="h-full bg-[#FAEDCA] text-[#2C2C2C]"
                            isOpen={isOpen}
                        />
                        <CardOverlayContent isOpen={isOpen} />
                    </div>
                </motion.div>
                <ToggleButton
                    isOpen={isOpen}
                    onClick={() => setIsOpen((v) => !v)}
                />
            </div>
        </article>
    );
};

export default CardClipPathHover;
Next

Interactive Bars