"use client"; /** * EventCard, from https://motion-brew.com/components/event-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 { useGSAP } from "@gsap/react"; import { type ClassValue, clsx } from "clsx"; import gsap from "gsap"; import { ArrowRight, Video } from "lucide-react"; import { AnimatePresence, motion, type Transition, useAnimation, useInView, 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: chevron-down const ChevronDownIcon = (() => { interface ChevronDownIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChevronDownIconProps 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 = { times: [0, 0.4, 1], duration: 0.5, }; const ChevronDownIcon = 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} > ); } ); ChevronDownIcon.displayName = "ChevronDownIcon"; return ChevronDownIcon; })(); // Animated icon: plus const PlusIcon = (() => { interface PlusIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface PlusIconProps 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 PlusIcon = 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} > ); } ); PlusIcon.displayName = "PlusIcon"; return PlusIcon; })(); // EventCard /** * EventCard * * Trigger: hover and press (plus in view). A minimal white card with the * details of one event: a small kind line over the title, then the date and * a time whose clock hands sweep round to the start when the card comes into * view. Guests sit in a stack that fans out on hover with a name under each * face, and the dashed plus adds a guest who pops in while the counts roll. * The description fades out under its last line and Show more unfolds the * rest. The action row pairs an ink Join button, whose arrow slides through * on hover, with round tools drawn in GSAP: the copy sheets slide apart and * swap to a check, the pencil rocks and draws a line, and the bin lifts its * lid, then widens into a red Delete pill to confirm and leaves an Undo row. * Everything rises in one row after another when the card scrolls into view. * Reduced motion swaps the movement for fades. * * Engine: motion/react for the layout, stack, counts and states; gsap + * @gsap/react for the icon drawings (scoped with useGSAP). */ gsap.registerPlugin(useGSAP); export interface EventGuest { name: string; /** Photo URL. Without one, initials on a soft disc. */ avatar?: string; /** Whether they said yes. Default `true`. */ going?: boolean; } export interface EventCardProps { /** Event title. Default `"Product demo"`. */ title?: string; /** When it starts. Default a Friday at 1:30 PM. */ start?: Date; /** When it ends. Default two hours after the start. */ end?: Date; /** People invited. Default six guests, one still to answer. */ guests?: EventGuest[]; /** What the event is about. Each string is a paragraph. */ about?: string[]; /** Locale for the date and time. Default `"en-US"`. */ locale?: string; /** Called by the pencil. */ onEdit?: () => void; /** Called once delete is confirmed. */ onDelete?: () => void; /** Called by the plus. Default adds a sample guest so the stack can be tried. */ onInvite?: () => void; /** What kind of event it is, shown as a tag. Default `"Video call"`. */ kind?: string; /** Main button label. Default `"Join call"`. */ cta?: string; /** Called by the main button. */ onJoin?: () => void; className?: string; } const GLIDE = [0.22, 1, 0.36, 1] as const; const POP = { type: "spring", stiffness: 520, damping: 24 } as const; const DANGER = "#d6453d"; const headshot = (id: string) => `https://images.unsplash.com/${id}?w=96&h=96&fit=crop&crop=faces`; const DEFAULT_GUESTS: EventGuest[] = [ { name: "Sienna Hart", avatar: headshot("photo-1494790108377-be9c29b29330") }, { name: "Marcus Lee", avatar: headshot("photo-1507003211169-0a1dd7228f2d") }, { name: "Priya Nair", avatar: headshot("photo-1534528741775-53994a69daeb") }, { name: "Leo Grant", avatar: headshot("photo-1500648767791-00dcc994a43e") }, { name: "Ava Brooks", avatar: headshot("photo-1438761681033-6461ffad8d80") }, { name: "Omar Reyes", going: false }, ]; const EXTRA_GUESTS: EventGuest[] = [ { name: "Nina Park", going: false }, { name: "Theo Ward", going: false }, { name: "Maya Cole", going: false }, ]; const DEFAULT_ABOUT = [ "Sienna is inviting you to a scheduled video call.", "Topic: a walk through the new dashboard, followed by questions from the team.", "Bring anything you want us to look at. The recording and notes will be shared afterwards.", ]; function defaultStart() { const d = new Date(); d.setDate(d.getDate() + ((5 - d.getDay() + 7) % 7 || 7)); d.setHours(13, 30, 0, 0); return d; } const initials = (name: string) => name .split(/\s+/) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase(); // --------------------------------------------------------------------------- // Icons drawn and moved with GSAP /** Two sheets that slide apart on hover. */ function CopyIcon({ hover, still }: { hover: boolean; still: boolean }) { const ref = React.useRef(null); useGSAP( () => { gsap.to("[data-front]", { x: hover && !still ? 1.5 : 0, y: hover && !still ? 1.5 : 0, duration: 0.3, ease: "power3.out", }); gsap.to("[data-back]", { x: hover && !still ? -1 : 0, y: hover && !still ? -1 : 0, duration: 0.3, ease: "power3.out", }); }, { scope: ref, dependencies: [hover] }, ); return ( ); } /** A bin whose lid lifts and tips on hover. */ function TrashIcon({ hover, still }: { hover: boolean; still: boolean }) { const ref = React.useRef(null); useGSAP( () => { gsap.to("[data-lid]", { y: hover && !still ? -2 : 0, rotation: hover && !still ? -14 : 0, svgOrigin: "4 6", duration: 0.35, ease: hover ? "back.out(3)" : "power2.out", }); }, { scope: ref, dependencies: [hover] }, ); return ( ); } /** A pencil that rocks and draws a short line on hover. */ function PencilIcon({ hover, still }: { hover: boolean; still: boolean }) { const ref = React.useRef(null); useGSAP( () => { gsap.set("[data-line]", { strokeDasharray: 1, strokeDashoffset: 1 }); if (!hover || still) { gsap.to("[data-pencil]", { rotation: 0, x: 0, duration: 0.25, svgOrigin: "4 20", }); gsap.to("[data-line]", { strokeDashoffset: 1, duration: 0.2 }); return; } gsap .timeline() .to("[data-pencil]", { keyframes: { rotation: [0, -8, 6, 0], x: [0, 1, 2, 2.5] }, duration: 0.5, ease: "sine.inOut", svgOrigin: "4 20", }) .to( "[data-line]", { strokeDashoffset: 0, duration: 0.45, ease: "power2.out" }, 0.05, ); }, { scope: ref, dependencies: [hover] }, ); return ( ); } /** A clock whose hands sweep round to the start time once it is in view. */ function SweepClock({ time, play, still, }: { time: Date; play: boolean; still: boolean; }) { const ref = React.useRef(null); const h = time.getHours(); const m = time.getMinutes(); useGSAP( () => { const hour = ((h % 12) + m / 60) * 30; const minute = m * 6; const o = { svgOrigin: "12 12", ease: "power3.out", duration: still ? 0 : 1.3, }; if (!play && !still) { gsap.set("[data-hour], [data-minute]", { rotation: 0, svgOrigin: "12 12", }); return; } gsap.to("[data-hour]", { rotation: hour, ...o }); gsap.to("[data-minute]", { rotation: minute + (still ? 0 : 360), ...o }); }, { scope: ref, dependencies: [play, h, m] }, ); return ( ); } // --------------------------------------------------------------------------- // Pieces /** A number that rolls up out of a blur when it changes. */ function Rolling({ text, still }: { text: string; still: boolean }) { return ( {text} ); } function ToolButton({ label, onClick, children, className, still, }: { label: string; onClick: () => void; children: (hover: boolean) => React.ReactNode; className?: string; still: boolean; }) { const [hover, setHover] = React.useState(false); return ( setHover(true)} onPointerLeave={() => setHover(false)} onFocus={() => setHover(true)} onBlur={() => setHover(false)} whileTap={still ? undefined : { scale: 0.9 }} className={cn( "grid size-12 shrink-0 cursor-pointer place-items-center rounded-full bg-zinc-100 text-zinc-700 outline-none transition-colors hover:bg-zinc-200/80 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/6 dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-zinc-100", className, )} > {children(hover)} ); } const ROWS = { hidden: {}, shown: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } }, }; const ROW = { hidden: { opacity: 0, y: 12, filter: "blur(4px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.45, ease: GLIDE }, }, }; export const EventCard = React.forwardRef( function EventCard( { title = "Product demo", start: startProp, end: endProp, guests: guestsProp, about = DEFAULT_ABOUT, locale = "en-US", onEdit, onDelete, onInvite, kind = "Video call", cta = "Join call", onJoin, className, }, ref, ) { const still = !!useReducedMotion(); const card = React.useRef(null); React.useImperativeHandle(ref, () => card.current as HTMLDivElement); const inView = useInView(card, { once: true, amount: 0.3 }); const show = still || inView; const start = React.useMemo(() => startProp ?? defaultStart(), [startProp]); const end = React.useMemo( () => endProp ?? new Date(start.getTime() + 2 * 3600_000), [endProp, start], ); const [added, setAdded] = React.useState([]); const guests = [...(guestsProp ?? DEFAULT_GUESTS), ...added]; const yes = guests.filter((g) => g.going !== false).length; const [fanned, setFanned] = React.useState(false); const [hoverGuest, setHoverGuest] = React.useState(null); const [copied, setCopied] = React.useState(false); const [confirming, setConfirming] = React.useState(false); const [binHover, setBinHover] = React.useState(false); const [deleted, setDeleted] = React.useState(false); const [open, setOpen] = React.useState(false); const [status, setStatus] = React.useState(""); const date = start.toLocaleDateString(locale, { weekday: "long", month: "short", day: "numeric", year: "numeric", }); const t = (d: Date) => d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }); const time = `${t(start)} to ${t(end)}`; const copy = async () => { const text = `${title}\n${date}\n${time}`; try { await navigator.clipboard?.writeText(text); } catch { /* The swap still confirms the press. */ } setCopied(true); setStatus("Event details copied."); window.setTimeout(() => setCopied(false), 1400); }; // Delete asks first; the question times out on its own. React.useEffect(() => { if (!confirming) return; const id = window.setTimeout(() => setConfirming(false), 3000); return () => window.clearTimeout(id); }, [confirming]); const remove = () => { if (!confirming) return setConfirming(true); setConfirming(false); setDeleted(true); setStatus("Event deleted."); onDelete?.(); }; const invite = () => { if (onInvite) return onInvite(); const next = EXTRA_GUESTS[added.length % EXTRA_GUESTS.length]!; setAdded((a) => [...a, { ...next }]); setStatus(`${next.name} invited.`); }; const STEP = fanned ? 34 : 24; const month = start.toLocaleDateString(locale, { month: "short" }); const weekday = start.toLocaleDateString(locale, { weekday: "short" }); const arrow = "absolute inset-0 size-4 transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"; return (
{/* Header: a small kind line, the title, then date and time rows. */}

{title}

{date}

{time}

{deleted ? ( This event was deleted. ) : ( {/* Guests: the stack fans out on hover with a name under each face. */}

{" "} guests

going,{" "} {" "} awaiting

setFanned(true)} onPointerLeave={() => { setFanned(false); setHoverGuest(null); }} > {guests.map((g, i) => ( setHoverGuest(i)} > {g.avatar ? ( {g.name} ) : ( initials(g.name) )} {g.going === false && ( )} {hoverGuest === i && ( {g.name} {g.going === false ? ", awaiting" : ""} )} ))}
{/* About: fades out under its last line until Show more unfolds the rest. */}
{about.map((para, i) => (

{para}

))}
{/* Actions: the main button, then round tools whose drawings move on hover. */} onJoin?.()} whileTap={still ? undefined : { scale: 0.98 }} transition={ still ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 36 } } style={{ borderRadius: 999 }} className="group flex h-12 min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 whitespace-nowrap bg-[#242423] px-5 text-sm font-medium text-white outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:bg-white dark:text-zinc-900 dark:focus-visible:ring-offset-zinc-900" > {cta} {!confirming && ( {(hover) => ( {copied ? ( ) : ( )} )} )} {!confirming && ( onEdit?.()} still={still} > {(hover) => } )} setBinHover(true)} onPointerLeave={() => setBinHover(false)} transition={ still ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 } } style={{ borderRadius: 999 }} animate={{ backgroundColor: confirming ? DANGER : binHover ? "rgba(120,120,120,0.18)" : "rgba(120,120,120,0.1)", }} className={cn( "flex h-12 shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-zinc-400", confirming ? "px-5 text-white" : "w-12 text-zinc-700 dark:text-zinc-300", )} > {confirming && ( Delete )}
)}
{status}
); }, );