"use client"; /** * ContactCard, from https://motion-brew.com/components/contact-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 { ArrowRight, ArrowUpRight, Mail } from "lucide-react"; import { AnimatePresence, motion, type Transition, useAnimation, useReducedMotion, 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: calendar-days const CalendarDaysIcon = (() => { interface CalendarDaysIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CalendarDaysIconProps 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 DOTS = [ { cx: 8, cy: 14 }, { cx: 12, cy: 14 }, { cx: 16, cy: 14 }, { cx: 8, cy: 18 }, { cx: 12, cy: 18 }, { cx: 16, cy: 18 }, ]; const VARIANTS: Variants = { normal: { opacity: 1, transition: { duration: 0.2, }, }, animate: (i: number) => ({ opacity: [1, 0.3, 1], transition: { delay: i * 0.1, duration: 0.4, times: [0, 0.5, 1], }, }), }; const CalendarDaysIcon = forwardRef< CalendarDaysIconHandle, CalendarDaysIconProps >(({ 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} > {DOTS.map((dot, index) => ( ))} ); }); CalendarDaysIcon.displayName = "CalendarDaysIcon"; return CalendarDaysIcon; })(); // 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: copy const CopyIcon = (() => { interface CopyIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CopyIconProps 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 DEFAULT_TRANSITION: Transition = { type: "spring", stiffness: 160, damping: 17, mass: 1, }; const CopyIcon = 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} > ); } ); CopyIcon.displayName = "CopyIcon"; return CopyIcon; })(); // Animated icon: linkedin const LinkedinIcon = (() => { interface LinkedinIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface LinkedinIconProps 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, pathOffset: 0, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.6, ease: "linear", opacity: { duration: 0.1 }, }, }, }; const RECT_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, pathOffset: 0, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.6, ease: "linear", opacity: { duration: 0.1 }, }, }, }; const CIRCLE_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, pathOffset: 0, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.6, ease: "linear", opacity: { duration: 0.1 }, }, }, }; const LinkedinIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const pathControls = useAnimation(); const rectControls = useAnimation(); const circleControls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => { pathControls.start("animate"); rectControls.start("animate"); circleControls.start("animate"); }, stopAnimation: () => { pathControls.start("normal"); rectControls.start("normal"); circleControls.start("normal"); }, }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { pathControls.start("animate"); rectControls.start("animate"); circleControls.start("animate"); } }, [circleControls, onMouseEnter, pathControls, rectControls] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { pathControls.start("normal"); rectControls.start("normal"); circleControls.start("normal"); } }, [pathControls, rectControls, circleControls, 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} > ); } ); LinkedinIcon.displayName = "LinkedinIcon"; return LinkedinIcon; })(); // ContactCard /** * ContactCard * * Trigger: hover (plus focus and press). A Contact link that lives inside a * sentence ("Not just a simple Contact button."), set in the sentence's own * size, weight and colour with just an arrow after it. Hovering draws an * underline under it, turns the arrow up toward the card and lifts a white * contact card out of it with a soft spring and a blur, its rows rising in one * after another: an avatar with a breathing "available" dot, the name * and role, the email address, a LinkedIn link and a Book a call button. * Pressing the email copies it, the icon flipping to a check while the label * rolls to Copied. The LinkedIn and Book arrows slide through on hover. The * card stays open while the pointer travels from the link onto it, and * closes a moment after it leaves, on Escape, or on a click elsewhere. Keyboard focus and a tap on * touch open it too. Reduced motion keeps every state but drops the movement. * * Engine: motion/react (spring pop-over with a staggered reveal, rolling * labels and icon swaps). */ export interface ContactCardProps { /** Pill label. Default `"Contact"`. */ label?: string; /** Who you are reaching. Default `"Maya Lindqvist"`. */ name?: string; /** Their role. Default `"Design lead"`. */ role?: string; /** Their photo. */ avatar?: string; /** Email address. Default `"maya@studio.co"`. */ email?: string; /** LinkedIn profile URL. */ linkedin?: string; /** Booking link. Without it the button calls `onBook`. */ bookHref?: string; /** Called by the book button when there is no `bookHref`. */ onBook?: () => void; /** Book button label. Default `"Book a call"`. */ bookLabel?: string; /** Show the available dot. Default `true`. */ available?: boolean; className?: string; } const GLIDE = [0.22, 1, 0.36, 1] as const; const CLOSE_DELAY = 220; const STAGGER = { shut: {}, open: { transition: { staggerChildren: 0.05, delayChildren: 0.06 } }, }; const RISE = { shut: { opacity: 0, y: 8, filter: "blur(4px)" }, open: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.35, ease: GLIDE }, }, }; /** An arrow that slides out to the right while a fresh one slides in, on the parent's hover. */ function Through({ still, className }: { still: boolean; className?: string }) { const arrow = "absolute inset-0 size-4 transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"; return ( ); } export const ContactCard = React.forwardRef( function ContactCard( { label = "Contact", name = "Maya Lindqvist", role = "Design lead", avatar = "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=120&h=120&fit=crop&crop=faces", email = "maya@studio.co", linkedin = "https://www.linkedin.com/", bookHref, onBook, bookLabel = "Book a call", available = true, className, }, ref, ) { const still = !!useReducedMotion(); const id = React.useId(); const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current as HTMLDivElement); const [open, setOpen] = React.useState(false); const [copied, setCopied] = React.useState(false); const timer = React.useRef(0); const first = React.useRef(null); // When the pill had keyboard focus, hand focus to the card's first row once it opens. const fromKeys = React.useRef(false); React.useEffect(() => { if (!open || !fromKeys.current) return; fromKeys.current = false; requestAnimationFrame(() => first.current?.focus()); }, [open]); const show = () => { window.clearTimeout(timer.current); setOpen(true); }; const hideSoon = () => { window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY); }; React.useEffect(() => { if (!open) return; const away = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("pointerdown", away); document.addEventListener("keydown", esc); return () => { document.removeEventListener("pointerdown", away); document.removeEventListener("keydown", esc); }; }, [open]); React.useEffect(() => () => window.clearTimeout(timer.current), []); const copy = async () => { try { await navigator.clipboard?.writeText(email); } catch { /* the swap still confirms the press */ } setCopied(true); window.setTimeout(() => setCopied(false), 1600); }; // A row in the soft contact block: icon disc, a small label over the value, a round action. const tile = "group/row flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition-colors hover:bg-white/70 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:hover:bg-white/6"; const disc = "grid size-9 shrink-0 place-items-center rounded-full bg-white text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:bg-zinc-800 dark:text-zinc-200"; const action = "grid size-8 shrink-0 place-items-center rounded-full text-zinc-500 transition-colors group-hover/row:bg-white group-hover/row:text-zinc-900 dark:group-hover/row:bg-zinc-800 dark:group-hover/row:text-zinc-100"; const Book = bookHref ? "a" : "button"; const dot = (size: string) => available && ( {!still && ( )} ); return (
e.pointerType === "mouse" && show()} onPointerLeave={(e) => e.pointerType === "mouse" && hideSoon()} onBlur={(e) => { const to = e.relatedTarget as Node | null; if (to && !root.current?.contains(to)) hideSoon(); }} > {open && ( {/* The padding under the card is a bridge, so the pointer can cross from the link to the card. */} {/* Header: who, what they do, and whether they are free. */} {dot("size-3.5")} {name} {role} {available && ( Available )} {/* Ways to reach them, in one soft block. */}
LinkedIn {linkedin .replace(/^https?:\/\/(www\.)?/, "") .replace(/\/$/, "")} {bookLabel} )} {/* An inline link in the sentence's own type: same size, weight and colour, with just an arrow after it and an underline that draws in on hover or while the card is open. */} (open ? setOpen(false) : show())} onFocus={() => { fromKeys.current = true; show(); }} whileTap={still ? undefined : { scale: 0.97 }} className="group relative inline-flex cursor-pointer items-baseline gap-[0.12em] rounded-md text-inherit outline-none [font:inherit] [letter-spacing:inherit] focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4 dark:focus-visible:ring-offset-zinc-900" > {label}
); }, );