"use client"; /** * ActionMenu, from https://motion-brew.com/components/action-menu * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * 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 { 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: arrow-up-right const ArrowUpRightIcon = (() => { interface ArrowUpRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowUpRightIconProps 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 ARROW_VARIANTS: Variants = { normal: { scale: 1, translateX: 0, translateY: 0, }, animate: { scale: [1, 0.85, 1], translateX: [0, -4, 0], translateY: [0, 4, 0], originX: 1, originY: 0, transition: { duration: 0.5, ease: "easeInOut", }, }, }; const ArrowUpRightIcon = forwardRef< ArrowUpRightIconHandle, ArrowUpRightIconProps >(({ 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) controls.start("animate"); onMouseEnter?.(e); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("normal"); onMouseLeave?.(e); }, [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} > ); }); ArrowUpRightIcon.displayName = "ArrowUpRightIcon"; return ArrowUpRightIcon; })(); // 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; })(); // ActionMenu /** * ActionMenu * * Trigger: press, then hover. A dropdown of actions and links. Opening springs * the panel out of its trigger while the items cascade in. A soft highlight * slides between items as you hover or arrow through them. Copy actions shrink * their icon away and draw a check while the label swaps to "Copied"; download * actions bounce their arrow while a light gray water fill washes across the * row, then draw the check. Link items nudge their arrow. Escape or a click outside closes it and * focus returns to the trigger. * * Engine: motion/react + gsap (Motion for the panel spring, sliding highlight, * label swaps and arrow nudges; gsap + @gsap/react for the item cascade and * the icon timelines). */ gsap.registerPlugin(useGSAP); const GLIDE = [0.22, 1, 0.36, 1] as const; const PANEL = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 } as const; const SWAP = { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.2, ease: GLIDE }, } as const; // Wavy leading edge for the download fill, the same water edge as ReceiptButton. const WAVE_W = 20; /** Two periods of a vertical sine, filled to its left, in a 20 x 96 box; scrolled by half, it loops. */ function wavePath(amplitude: number, phase: number) { const pts: string[] = []; for (let y = 0; y <= 96; y += 4) { const x = WAVE_W / 2 + amplitude * Math.sin((y / 48) * Math.PI * 2 + phase); pts.push(`${x.toFixed(2)} ${y}`); } return `M0 0 L${pts.join(" L")} L0 96 Z`; } const WAVE_FRONT = wavePath(6, 0); const WAVE_BACK = wavePath(7.5, Math.PI / 1.5); export type ActionMenuItem = | { kind: "copy" | "download"; label: string; /** Label while the action confirms. Default "Copied" or "Downloaded". */ doneLabel?: string; /** Text written to the clipboard for copy items. */ copyText?: string; /** Runs when chosen. Download items wait for it before showing the check. */ onSelect?: () => void | Promise; } | { kind: "link"; label: string; href?: string; onSelect?: () => void } | { kind: "separator" }; export interface ActionMenuProps { /** Trigger button label. Default `"Brand assets"`. */ label?: React.ReactNode; /** Menu items, top to bottom. Default a brand-asset menu. */ items?: ActionMenuItem[]; /** Start open. Default `false`. */ defaultOpen?: boolean; /** How long a confirmed action shows its check, in ms. Default `1400`. */ confirmFor?: number; className?: string; } const DEFAULT_ITEMS: ActionMenuItem[] = [ { kind: "copy", label: "Copy logo SVG", copyText: "" }, { kind: "copy", label: "Copy wordmark SVG", copyText: "" }, { kind: "download", label: "Download SVGs" }, { kind: "separator" }, { kind: "link", label: "Usage guidelines" }, { kind: "link", label: "Download press kit" }, ]; /** Two offset rounded squares. */ function CopyIcon() { return ( ); } /** Cloud with a separate arrow group, so the arrow can bounce on its own. */ function DownloadIcon() { return ( ); } function ActionRow({ item, index, active, highlightId, confirmFor, onHover, }: { item: Extract; index: number; active: boolean; highlightId: string; confirmFor: number; onHover: () => void; }) { const row = React.useRef(null); const [done, setDone] = React.useState(false); const busy = React.useRef(false); const { contextSafe } = useGSAP({ scope: row }); // Look parts up inside this row only. A selector string can match other rows' icons. const $ = (sel: string) => (row.current ? Array.from(row.current.querySelectorAll(sel)) : []); const run = contextSafe(async () => { if (busy.current) return; busy.current = true; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const tl = gsap.timeline(); if (item.kind === "download") { // Water fill: a light gray wash slides across the row while its wavy edge ripples. const ripples = [ gsap.fromTo( $("[data-wave=front]"), { yPercent: 0 }, { yPercent: -50, duration: 0.55, ease: "none", repeat: -1 }, ), gsap.fromTo($("[data-wave=back]"), { yPercent: -50 }, { yPercent: 0, duration: 0.85, ease: "none", repeat: -1 }), ]; tl.to($("[data-arrow]"), { y: 3, duration: 0.18, ease: "power2.in", yoyo: true, repeat: 3 }).fromTo( $("[data-progress]"), { xPercent: -100, x: -WAVE_W, opacity: 1 }, { xPercent: 0, x: 0, duration: 0.9, ease: "power2.inOut", onComplete: () => ripples.forEach((r) => r.kill()), }, 0, ); } try { if (item.kind === "copy" && item.copyText) await navigator.clipboard?.writeText(item.copyText); await item.onSelect?.(); } catch { /* the action failed or the clipboard is blocked; still finish the animation */ } // Swap the icon for a check that draws itself. tl.to($("[data-face]"), { scale: 0.5, opacity: 0, duration: 0.15, ease: "power2.in", transformOrigin: "50% 50%" }) .set($("[data-check]"), { opacity: 1 }) .fromTo($("[data-check]"), { strokeDashoffset: 24 }, { strokeDashoffset: 0, duration: 0.3, ease: "power2.out" }) .to($("[data-progress]"), { opacity: 0, duration: 0.35, ease: "power2.out" }, "<") .add(() => setDone(true), "<"); if (reduce) tl.progress(1); await tl.then(); window.setTimeout( contextSafe(() => { setDone(false); gsap .timeline({ onComplete: () => void (busy.current = false) }) .to($("[data-check]"), { opacity: 0, duration: 0.15 }) .fromTo( $("[data-face]"), { scale: 0.5, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.35, ease: "back.out(2)", transformOrigin: "50% 50%" }, ) .set($("[data-progress]"), { xPercent: -100, x: -WAVE_W }); }), confirmFor, ); }); const doneLabel = item.doneLabel ?? (item.kind === "copy" ? "Copied" : "Downloaded"); return ( ); } function LinkRow({ item, index, active, highlightId, onHover, onPick, }: { item: Extract; index: number; active: boolean; highlightId: string; onHover: () => void; onPick: () => void; }) { const Tag = item.href ? motion.a : motion.button; return ( { item.onSelect?.(); onPick(); }} initial="rest" animate={active ? "hover" : "rest"} className={cn( "relative flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm font-medium outline-none transition-colors", active ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-500 dark:text-zinc-400", )} > {active && ( )} {item.label} ); } export const ActionMenu = React.forwardRef(function ActionMenu( { label = "Brand assets", items = DEFAULT_ITEMS, defaultOpen = false, confirmFor = 1400, className }, forwardedRef, ) { const root = React.useRef(null); React.useImperativeHandle(forwardedRef, () => root.current as HTMLDivElement); const triggerRef = React.useRef(null); const panelRef = React.useRef(null); const [open, setOpen] = React.useState(defaultOpen); const [active, setActive] = React.useState(null); const uid = React.useId(); const highlightId = `${uid}-highlight`; const menuId = `${uid}-menu`; const selectable = items.flatMap((it, i) => (it.kind === "separator" ? [] : [i])); // Items cascade in each time the panel opens. useGSAP( () => { if (!open) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const rows = panelRef.current?.querySelectorAll("[data-row]"); if (!rows?.length) return; gsap.fromTo( rows, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.32, ease: "power2.out", stagger: 0.035, delay: 0.04 }, ); }, { scope: panelRef, dependencies: [open] }, ); // Close on a click outside. React.useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); // Move focus with the highlight so keyboard users and screen readers follow it. React.useEffect(() => { if (!open || active === null) return; const el = panelRef.current?.querySelector(`[data-item="${active}"]`); if (el && document.activeElement !== el && panelRef.current?.contains(document.activeElement)) el.focus(); }, [active, open]); const close = (refocus = true) => { setOpen(false); setActive(null); if (refocus) triggerRef.current?.focus(); }; const move = (dir: 1 | -1 | "first" | "last") => { const at = active === null ? -1 : selectable.indexOf(active); const next = dir === "first" ? 0 : dir === "last" ? selectable.length - 1 : (at + dir + selectable.length) % selectable.length; const target = selectable[next]!; setActive(target); panelRef.current?.querySelector(`[data-item="${target}"]`)?.focus(); }; return (
setOpen((o) => !o)} onKeyDown={(e) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setOpen(true); requestAnimationFrame(() => move(e.key === "ArrowDown" ? "first" : "last")); } }} whileTap={{ scale: 0.97 }} className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-[#242423]/6 px-4 py-2 text-sm font-medium text-[#242423] transition-colors hover:bg-[#242423]/10 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/8 dark:text-zinc-100 dark:hover:bg-white/12" > {label} {open && ( setActive(null)} onKeyDown={(e) => { if (e.key === "ArrowDown") (e.preventDefault(), move(1)); if (e.key === "ArrowUp") (e.preventDefault(), move(-1)); if (e.key === "Home") (e.preventDefault(), move("first")); if (e.key === "End") (e.preventDefault(), move("last")); if (e.key === "Escape") (e.preventDefault(), close()); if (e.key === "Tab") close(false); }} className="absolute left-0 top-full z-20 mt-2 w-64 rounded-2xl bg-white p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_16px_40px_-12px_rgba(0,0,0,0.22)] ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10" > {items.map((item, i) => item.kind === "separator" ? (
) : (
{item.kind === "link" ? ( setActive(i)} onPick={() => close()} /> ) : ( setActive(i)} /> )}
), )} )}
); });