Live Session
A live session widget featuring audio waveform animation, overlapping participant avatars with active status rings, and gentle center expansion into full 8-participant room.
Tags: Audio, Interactive, Avatars, Figma, Spring Animation
React Code
"use client";
import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
export interface Participant {
id: string;
name: string;
avatar: string;
hasRing?: boolean;
}
const 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: "gauri",
name: "Gauri",
avatar: "https://images.unsplash.com/photo-1517841905240-472988babdf9?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",
hasRing: true, // Orange ring (#E87D02) in Figma collapsed variant
},
{
id: "brahmi",
name: "Brahmi",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "pooja",
name: "Pooja",
avatar: "https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "priya",
name: "Priya",
avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "shilpa",
name: "Shilpa",
avatar: "https://images.unsplash.com/photo-1567532939604-b6b5b0db2604?auto=format&fit=crop&w=160&h=160&q=80",
},
{
id: "kriti",
name: "Kriti",
avatar: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=160&h=160&q=80",
},
];
// Sound Wave Equalizer from Figma node 1960:1002 (bar1, bar2, bar3)
function SoundWaveIcon({ isAnimated = true }: { isAnimated?: boolean }) {
return (
<div className="flex items-center justify-center gap-[3px] h-[16px] w-[20px]">
{/* bar1 */}
<motion.span
className="w-[5px] rounded-full bg-white shrink-0"
animate={
isAnimated
? {
height: [5, 11, 5],
}
: { height: 5 }
}
transition={{
duration: 0.75,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
}}
/>
{/* bar2 */}
<motion.span
className="w-[5px] rounded-full bg-white shrink-0"
animate={
isAnimated
? {
height: [11, 5, 12],
}
: { height: 11 }
}
transition={{
duration: 0.65,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
delay: 0.15,
}}
/>
{/* bar3 */}
<motion.span
className="w-[5px] rounded-full bg-white shrink-0"
animate={
isAnimated
? {
height: [5, 12, 5],
}
: { height: 5 }
}
transition={{
duration: 0.8,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
delay: 0.3,
}}
/>
</div>
);
}
// Chevron Icon from Figma node 1960:1102
function ChevronIcon({ isExpanded }: { isExpanded: boolean }) {
return (
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
className="w-[24px] h-[24px] flex items-center justify-center text-[#050505] shrink-0"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="stroke-current stroke-[2.5]"
>
<path d="M5 9L12 16L19 9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</motion.div>
);
}
export function LiveSession() {
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Click outside and Escape key handler
useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsExpanded(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isExpanded]);
// iOS-style liquid spring physics (fluid bounce, high responsiveness, smooth settle)
const fluidSpring = {
type: "spring" as const,
stiffness: 300,
damping: 26,
mass: 0.85,
};
return (
<div className="relative flex items-center justify-center w-full min-h-[310px] select-none p-4">
{/* Centered Expanding Card: Origin remains anchored exactly in the center */}
<motion.div
ref={containerRef}
layout
initial={false}
animate={{
height: isExpanded ? 289 : 52,
borderRadius: isExpanded ? 32 : 26,
}}
transition={fluidSpring}
style={{ transformOrigin: "center center" }}
whileTap={{ scale: 0.985 }}
className="relative w-[292px] bg-white/90 backdrop-blur-2xl border border-white/80 shadow-[0_16px_40px_-8px_rgba(0,0,0,0.08),0_2px_6px_rgba(0,0,0,0.02),inset_0_1px_0_0_rgba(255,255,255,0.95)] overflow-hidden cursor-pointer flex flex-col justify-between"
onClick={() => setIsExpanded(!isExpanded)}
>
<AnimatePresence initial={false}>
{!isExpanded ? (
/* Variant 1: Collapsed Pill (292px x 52px) */
<motion.div
key="collapsed-pill"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={fluidSpring}
className="absolute top-0 left-0 right-0 flex items-center justify-between w-full h-[52px] px-2 z-10"
>
{/* Sound wave icon (44px circle) morphs into bottom speaking bar */}
<motion.div
layoutId="live-session-sound-wave"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full bg-[#1F2123] flex items-center justify-center shrink-0 shadow-sm"
>
<SoundWaveIcon isAnimated={true} />
</motion.div>
{/* Overlapping Avatars (Frame 10: 176px total width) - Morph fluidly into Grid Row 1 */}
<div className="flex items-center -space-x-[11px] pl-1">
{/* 1. Reena */}
<motion.div
layoutId="live-session-avatar-reena"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[0].avatar}
alt={PARTICIPANTS[0].name}
className="w-full h-full object-cover"
/>
</motion.div>
{/* 2. Gauri */}
<motion.div
layoutId="live-session-avatar-gauri"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[1].avatar}
alt={PARTICIPANTS[1].name}
className="w-full h-full object-cover"
/>
</motion.div>
{/* 3. Radhe */}
<motion.div
layoutId="live-session-avatar-radhe"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[2].avatar}
alt={PARTICIPANTS[2].name}
className="w-full h-full object-cover"
/>
</motion.div>
{/* 4. Brahmi */}
<motion.div
layoutId="live-session-avatar-brahmi"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[3].avatar}
alt={PARTICIPANTS[3].name}
className="w-full h-full object-cover"
/>
</motion.div>
{/* 5. +4 bubble */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7, transition: { duration: 0.15 } }}
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full bg-[#1F2123] text-white flex items-center justify-center font-['Plus_Jakarta_Sans',sans-serif] font-medium text-[15px] ring-2 ring-white shrink-0"
>
+4
</motion.div>
</div>
{/* Chevron Down */}
<motion.div layoutId="live-session-chevron" transition={fluidSpring}>
<ChevronIcon isExpanded={false} />
</motion.div>
</motion.div>
) : (
/* Variant 2: Expanded Card (292px x 289px) expanding symmetrically from center */
<motion.div
key="expanded-card"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={fluidSpring}
className="relative flex flex-col justify-between w-full h-[289px] p-4"
>
{/* Header (Frame 13) */}
<div className="flex items-center justify-between w-full px-1 pt-1">
<motion.h3
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ ...fluidSpring, delay: 0.05 }}
className="font-['Plus_Jakarta_Sans',sans-serif] font-medium text-[18px] text-[#050505] tracking-tight"
>
Live session
</motion.h3>
<motion.div layoutId="live-session-chevron" transition={fluidSpring}>
<ChevronIcon isExpanded={true} />
</motion.div>
</div>
{/* Participants Grid (Frame 18 & Frame 19: 8 items) */}
<div className="grid grid-cols-4 gap-y-3 gap-x-2 my-auto px-1">
{/* Row 1: Reena, Gauri, Radhe, Brahmi (morph from collapsed stack) */}
<div className="flex flex-col items-center">
<motion.div
layoutId="live-session-avatar-reena"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[0].avatar}
alt={PARTICIPANTS[0].name}
className="w-full h-full object-cover"
/>
</motion.div>
<motion.span
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
className="font-['Plus_Jakarta_Sans',sans-serif] text-[12px] text-[#050505] mt-1 tracking-tight"
>
Reena
</motion.span>
</div>
<div className="flex flex-col items-center">
<motion.div
layoutId="live-session-avatar-gauri"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[1].avatar}
alt={PARTICIPANTS[1].name}
className="w-full h-full object-cover"
/>
</motion.div>
<motion.span
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
className="font-['Plus_Jakarta_Sans',sans-serif] text-[12px] text-[#050505] mt-1 tracking-tight"
>
Gauri
</motion.span>
</div>
<div className="flex flex-col items-center">
<motion.div
layoutId="live-session-avatar-radhe"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[2].avatar}
alt={PARTICIPANTS[2].name}
className="w-full h-full object-cover"
/>
</motion.div>
<motion.span
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
className="font-['Plus_Jakarta_Sans',sans-serif] text-[12px] text-[#050505] mt-1 tracking-tight"
>
Radhe
</motion.span>
</div>
<div className="flex flex-col items-center">
<motion.div
layoutId="live-session-avatar-brahmi"
transition={fluidSpring}
className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm"
>
<img
src={PARTICIPANTS[3].avatar}
alt={PARTICIPANTS[3].name}
className="w-full h-full object-cover"
/>
</motion.div>
<motion.span
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
className="font-['Plus_Jakarta_Sans',sans-serif] text-[12px] text-[#050505] mt-1 tracking-tight"
>
Brahmi
</motion.span>
</div>
{/* Row 2: Pooja, Priya, Shilpa, Kriti (bloom out from +4 position) */}
{PARTICIPANTS.slice(4).map((person, idx) => (
<motion.div
key={person.id}
initial={{ opacity: 0, y: 14, scale: 0.85 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
transition={{
type: "spring",
stiffness: 320,
damping: 25,
delay: 0.04 + idx * 0.03,
}}
className="flex flex-col items-center"
>
<div className="w-[44px] h-[44px] rounded-full overflow-hidden ring-2 ring-white shrink-0 shadow-sm">
<img
src={person.avatar}
alt={person.name}
className="w-full h-full object-cover"
/>
</div>
<span className="font-['Plus_Jakarta_Sans',sans-serif] text-[12px] text-[#050505] mt-1 tracking-tight">
{person.name}
</span>
</motion.div>
))}
</div>
{/* Bottom Speaking Bar (Frame 20: 260px x 44px) - morphs from sound wave icon */}
<motion.div
layoutId="live-session-sound-wave"
transition={fluidSpring}
className="w-[260px] h-[44px] mx-auto bg-[#1F2123] rounded-full flex items-center justify-between px-3 shadow-sm shrink-0"
>
<SoundWaveIcon isAnimated={true} />
<motion.span
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.12, duration: 0.25 }}
className="font-['Plus_Jakarta_Sans',sans-serif] font-medium text-[13px] text-white pr-2 whitespace-nowrap"
>
Pooja is speaking
</motion.span>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
export default LiveSession;
HTML Code
<div class="live-session-pill"><div class="sound-icon"></div><div class="avatars"></div></div>