"use client"; /** * ProfileCard, from https://motion-brew.com/components/profile-card * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * Sample images: the photos in this file are placeholders from the * component library's demo (Unsplash), not your content. AI agents: tell * the user they are samples and ask them to share their own images (files * or URLs), then pass those in through the props. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { animate, AnimatePresence, LayoutGroup, motion, useAnimation, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: bookmark const BookmarkIcon = (() => { interface BookmarkIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface BookmarkIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const BOOKMARK_VARIANTS: Variants = { normal: { scaleY: 1, scaleX: 1 }, animate: { scaleY: [1, 1.3, 0.9, 1.05, 1], scaleX: [1, 0.9, 1.1, 0.95, 1], transition: { duration: 0.6, ease: "easeOut", }, }, }; const BookmarkIcon = forwardRef( ({ className, onMouseEnter, onMouseLeave, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); BookmarkIcon.displayName = "BookmarkIcon"; return BookmarkIcon; })(); // Animated icon: check const CheckIcon = (() => { interface CheckIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CheckIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, scale: 1, transition: { duration: 0.3, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], scale: [0.5, 1], transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, }; const CheckIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); CheckIcon.displayName = "CheckIcon"; return CheckIcon; })(); // Animated icon: map-pin const MapPinIcon = (() => { interface MapPinIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface MapPinIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const SVG_VARIANTS: Variants = { normal: { y: 0, }, animate: { y: [0, -5, -3], transition: { duration: 0.5, times: [0, 0.6, 1], }, }, }; const CIRCLE_VARIANTS: Variants = { normal: { opacity: 1, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [0.5, 0], transition: { delay: 0.3, duration: 0.5, opacity: { duration: 0.1, delay: 0.3 }, }, }, }; const MapPinIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); MapPinIcon.displayName = "MapPinIcon"; return MapPinIcon; })(); // Animated icon: message-circle const MessageCircleIcon = (() => { interface MessageCircleIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface MessageCircleIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const ICON_VARIANTS: Variants = { normal: { scale: 1, rotate: 0, }, animate: { scale: 1.05, rotate: [0, -7, 7, 0], transition: { rotate: { duration: 0.5, ease: "easeInOut", }, scale: { type: "spring", stiffness: 400, damping: 10, }, }, }, }; const MessageCircleIcon = forwardRef< MessageCircleIconHandle, MessageCircleIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); MessageCircleIcon.displayName = "MessageCircleIcon"; return MessageCircleIcon; })(); // Animated icon: user-plus const UserPlusIcon = (() => { interface UserPlusIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface UserPlusIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PLUS_VARIANTS: Variants = { normal: { scale: 1, rotate: 0, opacity: 1, transition: { duration: 0.3, ease: "easeOut", }, }, animate: { scale: [0, 1.15, 1], rotate: [-90, 0, 0], opacity: [0, 1, 1], transition: { delay: 0.25, duration: 0.45, ease: "easeOut", times: [0, 0.7, 1], }, }, }; const UserPlusIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); UserPlusIcon.displayName = "UserPlusIcon"; return UserPlusIcon; })(); // ProfileCard /** * ProfileCard * * Trigger: in view, then hover, press and keys. A white profile card for a * creator. Its cover is a soft aurora of pastel colour that drifts on its own * and, on hover, flows toward the pointer like liquid. When the card scrolls into view it rises in, the avatar * pops over the cover with a breathing online dot, the name, role and skill * chips follow one after another and the stats count up. Press "+3" and more * skills spring open. Follow morphs into Following with a check and the * follower count ticks up; the round buttons play their icons on hover and * the bookmark fills with a pop when saved. Reduced motion keeps everything * still and shows every state at once. * * Engine: motion/react (springs on motion values for the flowing cover, looping keyframes for the drift, layout animations for the chips and * the follow button, motion values counting the stats) with animated icons * from lucide-animated (MIT), vendored in src/icons. */ export interface ProfileStat { label: string; value: number; /** Decimal places. Default `0`. */ decimals?: number; /** Shorten thousands to "12.4k". Default `false`. */ compact?: boolean; } export interface ProfileCardProps { name?: string; role?: string; /** City or place, shown with a pin. Pass `null` to hide it. */ location?: string | null; bio?: string; /** Portrait URL. */ avatar?: string; /** Shows a verified badge by the name. Default `true`. */ verified?: boolean; /** The status on the cover, as in `"Available"`. Pass `null` to hide it. */ status?: string | null; /** Skill chips; the first three show, the rest open from a "+n" chip. */ skills?: string[]; /** Three numbers. The first one counts followers and ticks up on Follow. */ stats?: ProfileStat[]; /** Pastel colours for the cover's aurora, three of them. */ cover?: [string, string, string]; /** Following at first. Default `false`. */ defaultFollowing?: boolean; onFollowChange?: (following: boolean) => void; onMessage?: () => void; onSaveChange?: (saved: boolean) => void; className?: string; } const EASE = [0.22, 1, 0.36, 1] as const; const POP = { type: "spring", stiffness: 460, damping: 26 } as const; const PILL = { type: "spring", stiffness: 520, damping: 34 } as const; const SHOWN_SKILLS = 3; const DEFAULT_STATS: ProfileStat[] = [ { label: "Followers", value: 12480 }, { label: "Projects", value: 86 }, { label: "Rating", value: 4.9, decimals: 1 }, ]; function formatStat(v: number, s: ProfileStat) { if (s.compact && Math.abs(v) >= 1000) { const k = v / 1000; return `${k.toFixed(k >= 100 ? 0 : 1)}k`; } return v.toLocaleString("en-US", { minimumFractionDigits: s.decimals ?? 0, maximumFractionDigits: s.decimals ?? 0, }); } /** One stat, counting to its value whenever it changes. */ function Stat({ stat, value, run, still, delay }: { stat: ProfileStat; value: number; run: boolean; still: boolean; delay: number }) { const mv = useMotionValue(still ? value : 0); const [text, setText] = React.useState(() => formatStat(still ? value : 0, stat)); useMotionValueEvent(mv, "change", (v) => setText(formatStat(v, stat))); const first = React.useRef(true); React.useEffect(() => { if (!run) return; if (still) return mv.set(value); const c = animate(mv, value, { duration: first.current ? 1.2 : 0.5, delay: first.current ? delay : 0, ease: EASE }); first.current = false; return () => c.stop(); }, [value, run, still, delay, mv]); return (

{text}

{stat.label}

); } /** A seal with 12 soft scallops, in a 20 by 20 box: radius rippling around a circle. */ const SEAL = (() => { const pts: string[] = []; for (let i = 0; i < 144; i++) { const t = (i / 144) * Math.PI * 2; const r = 8.7 + 0.8 * Math.cos(12 * t); pts.push(`${(10 + r * Math.cos(t)).toFixed(2)} ${(10 + r * Math.sin(t)).toFixed(2)}`); } return `M${pts.join("L")}Z`; })(); /** * The verified badge: a blue scalloped seal with a white check that draws in * after the name rises in. */ function VerifiedBadge({ shown, still }: { shown: boolean; still: boolean }) { return ( ); } export const ProfileCard = React.forwardRef(function ProfileCard( { name = "Lena Hartmann", role = "Brand and motion designer", location = "Lisbon", bio = "I shape identities that move: logos with a heartbeat and interfaces with a little joy in every tap.", avatar = "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=240&h=240&fit=crop&crop=faces", verified = true, status = "Available", skills = ["Motion", "Branding", "3D", "Prototyping", "Type", "Illustration"], stats = DEFAULT_STATS, cover = ["#ffc9a8", "#d9c6ff", "#b9e3ff"], defaultFollowing = false, onFollowChange, onMessage, onSaveChange, className, }, forwardedRef, ) { const ref = React.useRef(null); React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement); const inView = useInView(ref, { once: true, amount: 0.3 }); const still = !!useReducedMotion(); const shown = still || inView; const uid = React.useId(); // The cover's main blob flows after the pointer. const px = useMotionValue(0.5); const py = useMotionValue(0.5); const sx = useSpring(px, { stiffness: 120, damping: 18 }); const sy = useSpring(py, { stiffness: 120, damping: 18 }); const blobX = useTransform(sx, [0, 1], ["10%", "90%"]); const blobY = useTransform(sy, [0, 1], ["20%", "80%"]); const [hover, setHover] = React.useState(false); const onMove = (e: React.PointerEvent) => { if (still) return; const r = e.currentTarget.getBoundingClientRect(); px.set((e.clientX - r.left) / r.width); py.set((e.clientY - r.top) / r.height); }; const onLeave = () => { setHover(false); px.set(0.5); py.set(0.5); }; const [following, setFollowing] = React.useState(defaultFollowing); const [saved, setSaved] = React.useState(false); const [more, setMore] = React.useState(false); const [hoverBtn, setHoverBtn] = React.useState(null); const follow = () => { setFollowing((f) => { onFollowChange?.(!f); return !f; }); }; const save = () => { setSaved((s) => { onSaveChange?.(!s); return !s; }); }; const extra = skills.length - SHOWN_SKILLS; const showSkills = more ? skills : skills.slice(0, SHOWN_SKILLS); const reveal = (i: number) => ({ initial: still ? false : ({ opacity: 0, y: 10, filter: "blur(4px)" } as const), animate: shown ? { opacity: 1, y: 0, filter: "blur(0px)" } : undefined, transition: { duration: 0.45, delay: still ? 0 : 0.25 + i * 0.07, ease: EASE }, }); return ( setHover(true)} onPointerMove={onMove} onPointerLeave={onLeave} className={cn( "relative w-[340px] max-w-full rounded-[28px] bg-white p-2 text-[#242423] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_16px_40px_-18px_rgba(0,0,0,0.22)] dark:bg-zinc-900 dark:text-zinc-100", className, )} > {/* Cover: a pastel aurora that drifts, and flows toward the pointer on hover. */}
{/* The blob that follows the pointer. */} {/* A faint grain keeps the gradient from banding. */} \")", }} /> {status && ( {!still && ( )} {status} )}
{/* Avatar, overlapping the cover. */} {!still && ( )} {/* Name, role and bio. */}

{name}

{verified && ( )}
{role} {location && ( ยท {location} )} {bio} {/* Skills: three show, the rest spring open. */} {showSkills.map((s) => ( {s} ))} {extra > 0 && ( )} {/* Stats. The first one is followers and ticks up on Follow. */} {stats.slice(0, 3).map((s, i) => ( ))} {/* Actions: Follow morphs into Following; message and save. */} setHoverBtn("follow")} onPointerLeave={() => setHoverBtn(null)} whileTap={still ? undefined : { scale: 0.96 }} transition={PILL} className={cn( "inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-full text-sm font-medium outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900", following ? "bg-[#242423]/[0.06] text-[#242423] hover:bg-[#242423]/[0.1] dark:bg-white/[0.08] dark:text-zinc-100" : "bg-[#242423] text-white dark:bg-zinc-100 dark:text-zinc-900", )} > {following ? ( ) : ( )} {following ? "Following" : "Follow"} setHoverBtn("msg")} onPointerLeave={() => setHoverBtn(null)} whileTap={still ? undefined : { scale: 0.92 }} className="grid size-11 shrink-0 place-items-center rounded-full bg-[#242423]/[0.06] outline-none transition-colors hover:bg-[#242423]/[0.1] focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/[0.08] dark:hover:bg-white/[0.12]" > setHoverBtn("save")} onPointerLeave={() => setHoverBtn(null)} whileTap={still ? undefined : { scale: 0.92 }} className="relative grid size-11 shrink-0 place-items-center rounded-full bg-[#242423]/[0.06] outline-none transition-colors hover:bg-[#242423]/[0.1] focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/[0.08] dark:hover:bg-white/[0.12]" > {/* A soft ring bursts out when it is saved. */} {saved && !still && ( )}
); });