Live Session Invite
An interactive invite widget featuring progressive blur contract transitions, email-to-avatar image detection, and dynamic fallback initials generation.
Tags: Invite, Interactive, Avatars, Progressive Blur, Figma
React Code
"use client";
import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
export interface Participant {
id: string;
name: string;
email?: string;
avatar?: string;
initials?: string;
isNew?: boolean;
}
// Initial 3 avatars from Figma design (State 1)
const INITIAL_PARTICIPANTS: Participant[] = [
{
id: "reena",
name: "Reena",
avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "radhe",
name: "Radhe",
avatar: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "gauri",
name: "Gauri",
avatar: "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&w=160&h=160&q=80",
},
];
// Known email to avatar dictionary (matching Figma State 5)
const KNOWN_AVATARS: Record<string, { name: string; avatar: string }> = {
"sahil@gmail.com": {
name: "Sahil",
// Exact man with dark hair and jacket from Figma State 5
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=160&h=160&q=80",
},
"alex@gmail.com": {
name: "Alex",
avatar: "https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&w=160&h=160&q=80",
},
"elena@gmail.com": {
name: "Elena",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=160&h=160&q=80",
},
"daniel@gmail.com": {
name: "Daniel",
avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=160&h=160&q=80",
},
};
// Generates first and last name first characters (initials) from email
function getInitialsFromEmail(email: string): string {
const username = email.split("@")[0].trim();
const parts = username.split(/[._\s-]+/).filter(Boolean);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
if (parts.length === 1 && parts[0].length >= 2) {
return parts[0].slice(0, 2).toUpperCase();
}
return (username.slice(0, 2) || "U").toUpperCase();
}
// Generates a soft vibrant gradient for initials avatar
function getInitialsColor(str: string): string {
const gradients = [
"from-indigo-500 to-purple-600",
"from-pink-500 to-rose-600",
"from-emerald-500 to-teal-600",
"from-amber-500 to-orange-600",
"from-sky-500 to-blue-600",
"from-violet-600 to-fuchsia-600",
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % gradients.length;
return gradients[index];
}
export function LiveSessionInvite() {
const [participants, setParticipants] = useState<Participant[]>(INITIAL_PARTICIPANTS);
const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [email, setEmail] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-focus input when expanded
useEffect(() => {
if (isExpanded) {
const timer = setTimeout(() => inputRef.current?.focus(), 80);
return () => clearTimeout(timer);
}
}, [isExpanded]);
// Click outside listener: collapse back to normal invite state if user clicks outside
useEffect(() => {
if (!isExpanded || isSubmitting) return;
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsExpanded(false);
setEmail("");
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
};
}, [isExpanded, isSubmitting]);
// iOS-style liquid spring physics
const fluidSpring = {
type: "spring" as const,
stiffness: 300,
damping: 26,
mass: 0.85,
};
const handleSend = () => {
const trimmed = email.trim();
if (!trimmed || isSubmitting) return;
const lower = trimmed.toLowerCase();
const known = KNOWN_AVATARS[lower];
let newParticipant: Participant;
if (known) {
newParticipant = {
id: `user-${Date.now()}`,
name: known.name,
email: trimmed,
avatar: known.avatar,
isNew: true,
};
} else {
newParticipant = {
id: `user-${Date.now()}`,
name: trimmed.split("@")[0],
email: trimmed,
initials: getInitialsFromEmail(trimmed),
isNew: true,
};
}
// Step 1: Start the progressive blur & compacting animation
setIsSubmitting(true);
// Step 2: Add avatar immediately so it drops in while the pill compacts
setParticipants(() => {
// Strictly maintain MAX 4 participants (matching Figma State 5!)
return [newParticipant, ...INITIAL_PARTICIPANTS];
});
// Step 3: Trigger the compacting transition to + Invite
setIsExpanded(false);
// Step 4: Settle back cleanly after animation completes
setTimeout(() => {
setIsSubmitting(false);
setEmail("");
}, 600);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSend();
} else if (e.key === "Escape") {
setIsExpanded(false);
setEmail("");
}
};
const handleReset = (e: React.MouseEvent) => {
e.stopPropagation();
setParticipants(INITIAL_PARTICIPANTS);
setIsExpanded(false);
setEmail("");
};
const isFull = participants.length >= 4;
return (
<div className="flex flex-col items-center justify-center w-full min-h-[160px] p-4 select-none">
<motion.div
ref={containerRef}
layout
transition={fluidSpring}
className="relative flex items-center h-[52px] bg-white border border-[#E9EBEF] rounded-full shadow-[0_4px_24px_rgba(0,0,0,0.06)] p-1 overflow-hidden"
>
{/* Avatars Stack (Frame 10) - Strictly max 4 avatars */}
<motion.div layout transition={fluidSpring} className="flex items-center -space-x-[11px] pl-0.5 shrink-0 z-10">
<AnimatePresence initial={false}>
{participants.map((person) => (
<motion.div
key={person.id}
layout
initial={
person.isNew
? { y: -48, scale: 0.6, opacity: 0 }
: { opacity: 1, scale: 1 }
}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{
type: "spring",
stiffness: 360,
damping: 24,
mass: 0.8,
}}
className="relative w-[44px] h-[44px] rounded-full shrink-0 shadow-sm ring-2 ring-white"
>
{person.avatar ? (
<img
src={person.avatar}
alt={person.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<div
className={`w-full h-full rounded-full bg-gradient-to-tr ${getInitialsColor(
person.email || person.name
)} flex items-center justify-center text-white font-['Plus_Jakarta_Sans',sans-serif] font-bold text-[14px] tracking-tight shadow-inner`}
title={person.email || person.name}
>
{person.initials}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
{/* Action Pill: Single continuous fluid morphing container without direct cut */}
<div className="relative pl-1 shrink-0">
<motion.div
layout
animate={{
width: isExpanded ? 291 : 94,
}}
transition={fluidSpring}
className="relative flex items-center h-[44px] bg-[#EEF1F4] rounded-full overflow-hidden"
>
{/* View A: "+ Invite" button (visible when collapsed) */}
<motion.button
type="button"
onClick={() => {
if (!isExpanded && !isSubmitting) {
setIsExpanded(true);
}
}}
animate={{
opacity: isExpanded ? 0 : 1,
x: isExpanded ? -20 : 0,
filter: isExpanded ? "blur(8px)" : "blur(0px)",
pointerEvents: isExpanded ? "none" : "auto",
}}
transition={{
duration: isExpanded ? 0.2 : 0.45,
ease: "easeInOut",
}}
className="absolute inset-0 flex items-center justify-center gap-1.5 px-4 h-full cursor-pointer text-[#1F2123] hover:text-black transition-colors"
>
{/* Plus Icon (Figma 1965:1263) */}
<svg
width="18"
height="18"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="stroke-current stroke-[2] shrink-0"
>
<path d="M10 4V16M4 10H16" strokeLinecap="round" />
</svg>
<span className="font-['Plus_Jakarta_Sans',sans-serif] font-medium text-[16px] tracking-tight whitespace-nowrap">
Invite
</span>
</motion.button>
{/* View B: Email Input & Send Button (fluidly expands, progressively blurs on submit) */}
<motion.div
animate={{
opacity: isExpanded ? 1 : 0,
x: isExpanded ? 0 : 25,
filter: isSubmitting
? "blur(12px)"
: isExpanded
? "blur(0px)"
: "blur(8px)",
pointerEvents: isExpanded && !isSubmitting ? "auto" : "none",
}}
transition={{
duration: isSubmitting ? 0.5 : 0.35,
ease: "easeInOut",
}}
className="flex items-center justify-between w-[291px] h-full pl-[12px] pr-[4px] shrink-0"
>
{/* Mail Icon & Input */}
<div className="flex items-center gap-2 flex-1 mr-2 min-w-0">
{/* Mail Icon (Figma 1965:1281) */}
<svg
width="18"
height="18"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="stroke-[#898B8E] stroke-[1.5] shrink-0"
>
<rect x="2.5" y="4.5" width="15" height="11" rx="2" />
<path d="M3.5 5.5L10 11L16.5 5.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<input
ref={inputRef}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="name@gmail.com"
disabled={!isExpanded || isSubmitting}
className="w-full bg-transparent font-['Plus_Jakarta_Sans',sans-serif] text-[15px] text-[#1F2123] placeholder-[#898B8E] focus:outline-none tracking-tight"
/>
</div>
{/* "send" button (Figma Frame 22) */}
<motion.button
type="button"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.95 }}
onClick={handleSend}
disabled={isSubmitting}
className="h-[36px] px-4 rounded-full bg-[#1F2123] hover:bg-black text-white font-['Plus_Jakarta_Sans',sans-serif] font-medium text-[14px] flex items-center justify-center shrink-0 shadow-sm cursor-pointer transition-colors"
>
send
</motion.button>
</motion.div>
{/* Progressive directional blur overlay sweeping across during contraction (Figma State 4) */}
<motion.div
animate={{
opacity: isSubmitting ? 1 : 0,
x: isSubmitting ? ["-100%", "0%", "100%"] : "-100%",
}}
transition={{
duration: 0.55,
ease: "easeInOut",
}}
className="absolute inset-0 pointer-events-none bg-gradient-to-r from-transparent via-[#EEF1F4]/80 to-[#EEF1F4] backdrop-blur-[6px]"
/>
</motion.div>
</div>
</motion.div>
{/* Subtle indicator / reset button when max 4 reached */}
{isFull && (
<motion.button
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
onClick={handleReset}
className="mt-2 text-[12px] font-['Plus_Jakarta_Sans',sans-serif] text-[#898B8E] hover:text-[#1F2123] transition-colors cursor-pointer"
>
4/4 members • Click to reset demo
</motion.button>
)}
</div>
);
}
export default LiveSessionInvite;
HTML Code
<div class="live-session-invite"><div class="avatars"></div><button class="invite-btn">+ Invite</button></div>