"use client"; import { useId } from "react"; /* * Team Invite – a small card illustration, in a single self-contained file. * Two connected teammates and an empty dashed slot. A cursor drags a third avatar into the slot: * it lands, the dashed line turns solid and the status dot lights up – then the scene starts over. * The artwork is laid out on a 600 × 400 grid inside a 3 : 2 frame and scales with its container. */ export type TeamInviteAvatar = { /** Image URL – a square portrait, shown in a circle */ src: string; /** Name for the accessible description */ name?: string; }; export type TeamInviteProps = { /** The two teammates that are already connected, then the one being invited */ avatars: [TeamInviteAvatar, TeamInviteAvatar, TeamInviteAvatar]; className?: string; /** Accessible description of the scene */ label?: string; }; export function TeamInvite({ avatars, className, label }: TeamInviteProps) { const id = "ui-ti" + useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [a, b, c] = avatars; const names = avatars.map((p, i) => p.name ?? `Teammate ${i + 1}`); return (
{/* React 19 hoists this into once, no matter how many instances render */} {/* Dragged from the right into the slot, with the cursor holding on */}
); } function Node({ avatar, online, className }: { avatar: TeamInviteAvatar; online?: boolean; className: string }) { return ( ); } /* ---------- Styles ---------- */ const css = /* css */ ` .ui-ti { /* 1 unit of the 600 × 400 layout grid */ --u: calc(100cqw / 600); --ui-ti-bubble: #ffffff; --ui-ti-bubble-ring: rgba(10, 10, 10, 0.06); --ui-ti-face: #eef0f4; /* Same gray as the lines in Outreach Branches */ --ui-ti-line-a: #e4e7ec; --ui-ti-line-b: #e4e7ec; --ui-ti-dash: #e4e7ec; --ui-ti-plus: #c4c4c4; --ui-ti-online: #34c46a; /* Ring around the status dot: mostly white with a hint of the green, fully opaque */ --ui-ti-dot-ring: #e2f6e9; --ui-ti-dot-edge: rgba(52, 196, 106, 0.1); --ui-ti-cursor: #141b34; --ui-ti-shadow: 0 calc(10 * var(--u)) calc(24 * var(--u)) rgba(0, 0, 0, 0.07), 0 calc(2 * var(--u)) calc(5 * var(--u)) rgba(0, 0, 0, 0.05); --ui-ti-lift: 0 calc(18 * var(--u)) calc(34 * var(--u)) rgba(0, 0, 0, 0.12), 0 calc(3 * var(--u)) calc(8 * var(--u)) rgba(0, 0, 0, 0.06); --ui-ti-loop: 4.4s; --ui-ti-ease: cubic-bezier(0.7, 0, 0.25, 1); position: relative; width: 100%; aspect-ratio: 3 / 2; overflow: hidden; container-type: inline-size; } .ui-ti *, .ui-ti *::before, .ui-ti *::after { box-sizing: border-box; } .ui-ti-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } .ui-ti-stop-a { stop-color: var(--ui-ti-line-a); } .ui-ti-stop-b { stop-color: var(--ui-ti-line-b); } .ui-ti-line { fill: none; stroke-width: 2.4; stroke-linecap: round; } .ui-ti-line--dashed { stroke: var(--ui-ti-dash); stroke-dasharray: 6 7.23; animation: ui-ti-dashed var(--ui-ti-loop) linear infinite; } .ui-ti-line--slot { stroke: var(--ui-ti-dash); stroke-dasharray: 6 7.23; } .ui-ti-line--joined { stroke-dasharray: 1; stroke-dashoffset: 1; animation: ui-ti-joined var(--ui-ti-loop) linear infinite; } /* ---------- Nodes ---------- */ .ui-ti-node { position: absolute; width: calc(84 * var(--u)); height: calc(84 * var(--u)); margin: calc(-42 * var(--u)) 0 0 calc(-42 * var(--u)); padding: calc(5 * var(--u)); border-radius: 50%; background: var(--ui-ti-bubble); box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--ui-ti-bubble-ring), var(--ui-ti-shadow); } .ui-ti-node--a { left: calc(120 * var(--u)); top: calc(159 * var(--u)); } .ui-ti-node--b { left: calc(300 * var(--u)); top: calc(105 * var(--u)); } .ui-ti-face { display: block; width: 100%; height: 100%; overflow: hidden; border-radius: 50%; background: var(--ui-ti-face); } .ui-ti-face img { display: block; width: 100%; height: 100%; object-fit: cover; user-select: none; } /* Status: a bright green dot in a thick, pale green ring with a faint 10% halo outside */ .ui-ti-dot { position: absolute; right: calc(1 * var(--u)); bottom: calc(3 * var(--u)); width: calc(24 * var(--u)); height: calc(24 * var(--u)); border: calc(7 * var(--u)) solid var(--ui-ti-dot-ring); border-radius: 50%; background: var(--ui-ti-online); box-shadow: 0 0 0 calc(2 * var(--u)) var(--ui-ti-dot-edge); } /* ---------- Empty slot ---------- */ .ui-ti-slot { transform-box: fill-box; transform-origin: center; animation: ui-ti-slot var(--ui-ti-loop) linear infinite; } .ui-ti-plus { fill: none; stroke: var(--ui-ti-plus); stroke-width: 1.8; stroke-linecap: round; } /* ---------- The invite being dragged in ---------- */ /* Starts on the right and is carried to the slot (290, 245) */ .ui-ti-drag { position: absolute; left: calc(456 * var(--u)); top: calc(281 * var(--u)); animation: ui-ti-drag var(--ui-ti-loop) infinite; } .ui-ti-node--c { left: 0; top: 0; animation: ui-ti-lift var(--ui-ti-loop) infinite; } .ui-ti-node--c .ui-ti-dot { animation: ui-ti-online var(--ui-ti-loop) infinite; } /* The pointer rests on the photo: its tip at (+16, +14) from the avatar's centre (the tip is 11.8% / 1.15% into the image) */ .ui-ti-cursor { position: absolute; left: calc(11.28 * var(--u)); top: calc(13.45 * var(--u)); width: calc(40 * var(--u)); height: calc(48.13 * var(--u)); overflow: visible; transform-origin: calc(4.72 * var(--u)) calc(0.55 * var(--u)); filter: drop-shadow(0 calc(3 * var(--u)) calc(7 * var(--u)) rgba(0, 0, 0, 0.16)); animation: ui-ti-cursor var(--ui-ti-loop) infinite; } .ui-ti-cursor path { fill: var(--ui-ti-cursor); stroke: #fff; stroke-width: 1.7; stroke-linejoin: round; paint-order: stroke; } /* * One loop: * (4.4 s) * 0–12% rest on the right 12–16% pick up (lift) * 16–42% carried to the slot 42–48% drop, settle * 48–54% line draws solid, dot pops 54–62% cursor lets go and drifts off * 62–88% hold the finished team 88–100% fade back to the start */ @keyframes ui-ti-drag { 0%, 16% { transform: translate(0, 0); opacity: 1; animation-timing-function: var(--ui-ti-ease); } 42%, 88% { transform: translate(calc(-166 * var(--u)), calc(-36 * var(--u))); opacity: 1; } 94% { transform: translate(calc(-166 * var(--u)), calc(-36 * var(--u))); opacity: 0; } 95% { transform: translate(0, 0); opacity: 0; } 100% { transform: translate(0, 0); opacity: 1; } } @keyframes ui-ti-lift { 0%, 12% { transform: scale(1); box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--ui-ti-bubble-ring), var(--ui-ti-shadow); animation-timing-function: var(--ui-ti-ease); } 16%, 40% { transform: scale(1.07); box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--ui-ti-bubble-ring), var(--ui-ti-lift); animation-timing-function: var(--ui-ti-ease); } 46%, 100% { transform: scale(1); box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--ui-ti-bubble-ring), var(--ui-ti-shadow); } } @keyframes ui-ti-online { 0%, 48% { transform: scale(0); } 52% { transform: scale(1.25); } 56%, 100% { transform: scale(1); } } @keyframes ui-ti-cursor { 0%, 11% { transform: translate(0, 0) scale(1); opacity: 1; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 14%, 42% { transform: translate(0, 0) scale(0.86); opacity: 1; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 47% { transform: translate(0, 0) scale(1); opacity: 1; animation-timing-function: var(--ui-ti-ease); } 62%, 88% { transform: translate(calc(70 * var(--u)), calc(44 * var(--u))) scale(1); opacity: 0; } 95% { transform: translate(calc(20 * var(--u)), calc(14 * var(--u))) scale(1); opacity: 0; } 100% { transform: translate(0, 0) scale(1); opacity: 1; } } @keyframes ui-ti-slot { 0%, 30% { opacity: 1; transform: scale(1); animation-timing-function: var(--ui-ti-ease); } 42%, 90% { opacity: 0; transform: scale(1.12); } 100% { opacity: 1; transform: scale(1); } } @keyframes ui-ti-dashed { 0%, 46% { opacity: 1; } 50%, 92% { opacity: 0; } 100% { opacity: 1; } } @keyframes ui-ti-joined { 0%, 44% { stroke-dashoffset: 1; opacity: 1; } 54%, 88% { stroke-dashoffset: 0; opacity: 1; } 94%, 100% { stroke-dashoffset: 0; opacity: 0; } } /* Reduced motion: a still frame of the waiting state */ @media (prefers-reduced-motion: reduce) { .ui-ti-drag, .ui-ti-node--c, .ui-ti-node--c .ui-ti-dot, .ui-ti-cursor, .ui-ti-slot, .ui-ti-line--dashed, .ui-ti-line--joined { animation: none; } .ui-ti-node--c .ui-ti-dot { transform: scale(0); } } /* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. */ :where(.dark, [data-theme="dark"]) .ui-ti { --ui-ti-bubble: #232326; --ui-ti-bubble-ring: rgba(255, 255, 255, 0.08); --ui-ti-face: #2e2e33; --ui-ti-line-a: rgba(255, 255, 255, 0.14); --ui-ti-line-b: rgba(255, 255, 255, 0.14); --ui-ti-dash: rgba(255, 255, 255, 0.14); --ui-ti-plus: #5c5c61; --ui-ti-online: #3fd17a; --ui-ti-dot-ring: #24392c; --ui-ti-dot-edge: rgba(63, 209, 122, 0.12); --ui-ti-shadow: 0 calc(10 * var(--u)) calc(24 * var(--u)) rgba(0, 0, 0, 0.4), 0 calc(2 * var(--u)) calc(5 * var(--u)) rgba(0, 0, 0, 0.3); --ui-ti-lift: 0 calc(18 * var(--u)) calc(34 * var(--u)) rgba(0, 0, 0, 0.5), 0 calc(3 * var(--u)) calc(8 * var(--u)) rgba(0, 0, 0, 0.3); } `;