Messenger Testimonial
Messenger Testimonial is a React and Tailwind CSS layout component from UI Layouts, licensed MIT. Copy it and paste it into your project.
UI Layouts
MIT
layout
What it is
Messenger Testimonial is a React and Tailwind CSS layout component from UI Layouts, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from UI Layouts and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Messenger Testimonial
'use client'
import { cn } from '@/lib/utils'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
interface Message {
id: number
sender: string
avatar: string
text: string
side: 'left' | 'right'
time: string
reactions: string[]
}
const messages: Message[] = [
{
id: 1,
sender: 'Emily Chen',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop',
text: 'Just integrated the new grid system. Incredible work!',
side: 'left',
time: '2:34 PM',
reactions: ['👏', '🔥'],
},
{
id: 2,
sender: 'Team Lab',
avatar: 'TL',
text: 'So glad you like it Emily! Let us know if you need any help.',
side: 'right',
time: '2:35 PM',
reactions: ['❤️'],
},
{
id: 3,
sender: 'Markus T.',
avatar:
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop',
text: 'The documentation is actually readable for once. Thank you.',
side: 'left',
time: '2:37 PM',
reactions: ['😄', '💯'],
},
{
id: 4,
sender: 'Emily Chen',
avatar:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop',
text: 'Btw, the dark mode tokens are perfect.',
side: 'left',
time: '2:38 PM',
reactions: ['✨'],
},
{
id: 5,
sender: 'Sarah K.',
avatar:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100&h=100&fit=crop',
text: 'Migration took 10 minutes. Best decision we made this quarter!',
side: 'left',
time: '2:40 PM',
reactions: ['🚀', '💪'],
},
]
const TypingIndicator = () => (
<div className="flex gap-1 px-4 py-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="size-2 bg-zinc-400 rounded-full"
animate={{ scale: [1, 1.2, 1], opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1,
repeat: Infinity,
delay: i * 0.2,
ease: 'easeInOut',
}}
/>
))}
</div>
)
export const MessengerTestimonials: React.FC = () => {
const [visibleMessages, setVisibleMessages] = useState<number[]>([])
const [showTyping, setShowTyping] = useState(false)
const [activeReactions, setActiveReactions] = useState<Set<number>>(new Set())
useEffect(() => {
const showMessages = async () => {
for (let i = 0; i < messages.length; i++) {
// Show typing indicator
setShowTyping(true)
await new Promise((resolve) => setTimeout(resolve, 800))
// Hide typing and show message
setShowTyping(false)
setVisibleMessages((prev) => [...prev, i])
// Wait before next message
await new Promise((resolve) => setTimeout(resolve, 1200))
}
}
showMessages()
}, [])
const toggleReaction = (messageId: number) => {
setActiveReactions((prev) => {
const newSet = new Set(prev)
if (newSet.has(messageId)) {
newSet.delete(messageId)
} else {
newSet.add(messageId)
}
return newSet
})
}
return (
<section className="bg-zinc-100 min-h-screen px-4 font-manrope py-10">
{/* Header */}
<div className="max-w-xl mx-auto text-center">
<h2 className="text-4xl font-bold tracking-tight text-zinc-900 mb-4">
Live Customer Conversations
</h2>
<p className="text-zinc-600 ">
Watch real-time interactions between our support team and customers.
Experience the responsive, helpful communication that sets us apart.
</p>
</div>
{/* chat interface */}
<div className="max-w-xl mx-auto flex flex-col border-x border-neutral-200">
{/* Header */}
<div className="text-center py-5 pb-10 border-b border-neutral-200">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="inline-flex items-center gap-2 font-manrope bg-white border border-zinc-200 rounded-full px-4 py-2"
>
<div className="size-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs font-black uppercase tracking-[0.2em] text-zinc-600">
Live Customer Conversations
</span>
</motion.div>
</div>
{/* Messages */}
<div className="flex flex-col gap-3 p-5 border-b border-neutral-200">
<AnimatePresence>
{visibleMessages.map((index) => {
const msg = messages[index]
const showReactions = activeReactions.has(msg?.id || 0)
return (
<motion.div
key={msg?.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.4, ease: [0.32, 0.72, 0, 1] }}
className={cn(
'flex gap-2 max-w-[85%]',
msg?.side === 'right'
? 'ml-auto flex-row-reverse'
: 'mr-auto'
)}
>
{/* Avatar */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: 0.2,
type: 'spring',
stiffness: 500,
damping: 25,
}}
className={cn(
'size-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0 overflow-hidden',
msg?.side === 'right'
? 'bg-zinc-900 text-white'
: 'bg-zinc-200'
)}
>
{msg?.avatar.startsWith('http') ? (
<img
src={msg.avatar}
alt={msg.sender}
className="size-full object-cover"
/>
) : (
<span
className={
msg?.side === 'right' ? 'text-white' : 'text-zinc-900'
}
>
{msg?.avatar}
</span>
)}
</motion.div>
{/* Message Content */}
<div
className={cn(
'flex flex-col gap-1',
msg?.side === 'right' ? 'items-end' : 'items-start'
)}
>
{/* Sender Name */}
<span className="text-xs text-zinc-500 font-medium px-1">
{msg?.sender}
</span>
{/* Message Bubble */}
<div className="relative group">
<motion.div
whileHover={{ scale: 1.02 }}
className={cn(
'px-4 py-2.5 rounded-2xl text-sm -sm cursor-pointer transition-all',
msg?.side === 'right'
? 'bg-zinc-900 text-white rounded-tr-sm'
: 'bg-white border border-zinc-200 text-zinc-900 rounded-tl-sm hover:border-zinc-300'
)}
onClick={() => toggleReaction(msg?.id || 0)}
>
{msg?.text}
</motion.div>
{/* Reactions */}
<AnimatePresence>
{showReactions && (
<motion.div
initial={{ opacity: 0, scale: 0.8, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 5 }}
transition={{ duration: 0.2 }}
className={cn(
'absolute -bottom-3 flex gap-0.5 bg-white border border-zinc-200 rounded-full px-2 py-0.5 -md',
msg?.side === 'right' ? 'right-0' : 'left-0'
)}
>
{msg?.reactions.map((reaction, idx) => (
<motion.span
key={idx}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: idx * 0.05,
type: 'spring',
stiffness: 500,
}}
className="text-xs"
>
{reaction}
</motion.span>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Timestamp */}
<span className="text-[9px] text-zinc-400 font-medium px-1">
{msg?.time}
</span>
</div>
</motion.div>
)
})}
</AnimatePresence>
{/* Typing Indicator */}
<AnimatePresence>
{showTyping && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="flex gap-2 max-w-[85%] mr-auto"
>
<div className="size-8 rounded-full overflow-hidden shrink-0">
<img
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop"
alt="User typing"
className="size-full object-cover"
/>
</div>
<div className="bg-white border border-zinc-200 rounded-2xl rounded-tl-sm -sm">
<TypingIndicator />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="border-b bg-neutral-200 border-neutral-200 p-5 flex items-center justify-between backdrop-blur-sm"
>
<div className="flex items-center gap-3">
<div className="relative">
<div className="size-2 bg-green-500 rounded-full animate-pulse" />
<div className="absolute inset-0 size-2 bg-green-500 rounded-full animate-ping opacity-75" />
</div>
<div className="flex flex-col">
<p className="text-xs font-bold font-manrope text-zinc-900">
42 customers online
</p>
<p className="text-xs text-zinc-500">Average response: 2 min</p>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="text-xs font-semibold uppercase tracking-widest text-zinc-900 bg-white border border-zinc-300 px-4 py-2 rounded-lg transition-all"
>
View Feed →
</motion.button>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.5 }}
className="grid grid-cols-3 gap-2 p-5"
>
{[
{ label: 'Response Rate', value: '98%' },
{ label: 'Avg. Rating', value: '4.9★' },
{ label: 'Active Users', value: '2.4k' },
].map((stat, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1 + idx * 0.1 }}
className="bg-white border border-neutral-200 rounded-xl p-3 text-center"
>
<p className="text-lg font-bold text-zinc-900 font-manrope">
{stat.value}
</p>
<p className="text-xs text-zinc-500 uppercase tracking-wider">
{stat.label}
</p>
</motion.div>
))}
</motion.div>
</div>
</section>
)
}
import type { Variants } from 'motion/react';
import { type HTMLMotionProps, motion, useInView } from 'motion/react';
import type React from 'react';
type TimelineContentProps<T extends keyof HTMLElementTagNameMap> = {
children?: React.ReactNode;
animationNum: number;
className?: string;
timelineRef: React.RefObject<HTMLElement | null>;
as?: T;
customVariants?: Variants;
once?: boolean;
} & HTMLMotionProps<T>;
export const TimelineAnimation = <T extends keyof HTMLElementTagNameMap = 'div'>({
children,
animationNum,
timelineRef,
className,
as,
customVariants,
once = true,
...props
}: TimelineContentProps<T>) => {
const defaultSequenceVariants = {
visible: (i: number) => ({
filter: 'blur(0px)',
y: 0,
opacity: 1,
transition: {
delay: i * 0.5,
duration: 0.5,
},
}),
hidden: {
filter: 'blur(20px)',
y: 0,
opacity: 0,
},
};
const sequenceVariants = customVariants || defaultSequenceVariants;
const isInView = useInView(timelineRef, {
once,
});
const MotionComponent = motion[as || 'div'] as React.ElementType;
return (
<MotionComponent
initial='hidden'
animate={isInView ? 'visible' : 'hidden'}
custom={animationNum}
variants={sequenceVariants}
className={className}
{...props}
>
{children}
</MotionComponent>
);
};