"use client"; /** * TeamMorph, from https://motion-brew.com/components/team-morph * * 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 { type ClassValue, clsx } from "clsx"; import { AnimatePresence, LayoutGroup, 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: chevron-right const ChevronRightIcon = (() => { interface ChevronRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChevronRightIconProps 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 ChevronRightIcon = forwardRef< ChevronRightIconHandle, ChevronRightIconProps >(({ 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} > ); }); ChevronRightIcon.displayName = "ChevronRightIcon"; return ChevronRightIcon; })(); // 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; })(); // Animated icon: x const XIcon = (() => { interface XIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface XIconProps 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, }, animate: { opacity: [0, 1], pathLength: [0, 1], }, }; const XIcon = 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} > ); } ); XIcon.displayName = "XIcon"; return XIcon; })(); // TeamMorph /** * TeamMorph * * Trigger: press, then hover. A presence pill that morphs into a team card. At * rest it shows an overlapping stack of avatars, a "+n" chip and who is online; * hovering lifts the pill, ripples the stack and sends the chevron through its circle. Pressing morphs the pill itself into a card: the * shell flows from pill to rounded card while every avatar flies from its spot * in the stack to its row in a calm two-group list (Online, Away), then names * and roles cascade in. Close, Escape or a click outside morphs it back. Focus moves to * the close button on open and returns to the pill on close. * * Engine: motion/react (one shell that stays mounted and resizes with a * layout animation while its contents swap, shared layoutId avatars scoped by * LayoutGroup, and staggered variants for the rows). */ const GLIDE = [0.22, 1, 0.36, 1] as const; const MORPH = { type: "spring", stiffness: 420, damping: 38, mass: 0.9, } as const; // Card contents vanish at once on close, so nothing is left behind while the shell morphs back. const LEAVE = { opacity: 0, transition: { duration: 0.08 } } as const; export interface TeamMember { name: string; role: string; /** Avatar color. Defaults through a soft built-in palette. */ color?: string; /** Minutes since last seen; leave out when online. */ awayFor?: number; } export interface TeamMorphProps { /** Team name. Default `"Design team"`. */ team?: string; /** People, first ones are shown in the collapsed stack. Default five teammates. */ members?: TeamMember[]; /** Avatars shown in the collapsed stack. Default `3`. */ stack?: number; /** Start expanded. Default `false`. */ defaultOpen?: boolean; /** Called by the Invite button. Leave out to hide it. */ onInvite?: () => void; className?: string; } const PALETTE = [ "#2a9fbf", "#3d66d6", "#d6498f", "#9150e0", "#269a5a", "#cf7a1c", "#5b6472", "#c0435e", ]; const DEFAULT_MEMBERS: TeamMember[] = [ { name: "Alex Rivera", role: "Design lead" }, { name: "Elaine Park", role: "Product designer" }, { name: "Breno Costa", role: "Motion designer", awayFor: 12 }, { name: "Taitina Ellis", role: "Researcher" }, { name: "Raman Gill", role: "Design engineer", awayFor: 40 }, ]; const initials = (name: string) => name .split(/\s+/) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase(); /** Initials on a soft tinted disc, ringed in the card color so stacked avatars read apart. */ function Avatar({ member, color, size, layoutId, className, }: { member: TeamMember; color: string; size: number; layoutId?: string; className?: string; }) { return ( {initials(member.name)} ); } export const TeamMorph = React.forwardRef( function TeamMorph( { team = "Design team", members = DEFAULT_MEMBERS, stack = 3, defaultOpen = false, onInvite, className, }, forwardedRef, ) { const root = React.useRef(null); React.useImperativeHandle( forwardedRef, () => root.current as HTMLDivElement, ); const pillRef = React.useRef(null); const closeRef = React.useRef(null); const reduceMotion = useReducedMotion(); const uid = React.useId(); const [open, setOpen] = React.useState(defaultOpen); const [hover, setHover] = React.useState(false); const wasOpen = React.useRef(defaultOpen); const people = members.map((m, i) => ({ ...m, color: m.color ?? PALETTE[i % PALETTE.length]!, })); const online = people.filter((m) => m.awayFor === undefined).length; const extra = Math.max(0, people.length - stack); const t = reduceMotion ? { duration: 0 } : MORPH; // Hand focus across the morph, and close on Escape or a click outside. React.useEffect(() => { if (open) closeRef.current?.focus({ preventScroll: true }); else if (wasOpen.current) pillRef.current?.focus({ preventScroll: true }); wasOpen.current = open; if (!open) return; const onDown = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("pointerdown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{/* One shell stays mounted and resizes between pill and card, so nothing is swapped or crossfaded on the way back; only its contents change, and the avatars fly between them. */} {!open ? ( setOpen(true)} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} whileTap={reduceMotion ? undefined : { scale: 0.97 }} style={{ borderRadius: 28 }} className="flex cursor-pointer items-center gap-3 p-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-400" > {people.slice(0, stack).map((m, i) => ( 0 && "-ml-2")} style={{ zIndex: stack - i }} > ))} {extra > 0 && ( // The chip has nothing to fly from, so it waits out the morph and fades in once the pill settles. +{extra} )} {team} {!reduceMotion && ( )} {online} online {/* Expand hint: nudges toward the card on hover. */} {/* Expand hint: same size as an avatar and inset like the first one, so the pill reads balanced. On hover the tint deepens a shade and the chevron slides out and back in, clipped by the circle. */} {/* Rests 1px right: a right-pointing chevron looks left-heavy when centered by its box. */} ) : (

{team}

{people.length}

setOpen(false)} initial={{ opacity: 0, scale: 0.6, rotate: -90 }} animate={{ opacity: 1, scale: 1, rotate: 0, transition: { delay: 0.1, duration: 0.3, ease: GLIDE, }, }} whileHover={{ rotate: 90 }} whileTap={{ scale: 0.9 }} className="grid size-8 cursor-pointer place-items-center rounded-full text-zinc-500 transition-colors hover:bg-[#242423]/6 hover:text-zinc-900 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100" >
{/* Two quiet groups instead of a badge on every row. */} {[ { label: "Online", rows: people.filter((m) => m.awayFor === undefined), }, { label: "Away", rows: people.filter((m) => m.awayFor !== undefined), }, ] .filter((g) => g.rows.length > 0) .map((group, g) => ( {group.label} ยท {group.rows.length}
    {group.rows.map((m) => { const i = people.indexOf(m); const away = m.awayFor !== undefined; return (
  • {i < stack ? ( ) : ( // People hidden behind the "+n" chip have no stack avatar to fly from, so they pop in. )}

    {m.name}

    {m.role}

    {away && ( {m.awayFor}m )}
  • ); })}
))} {onInvite && ( Invite )}
)}
); }, );