Chat Bubbles
Original · freemessage bubble styles both sides
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chat-bubbles.json"use client";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Sender = "them" | "me";
type Status = "sent" | "delivered" | "read";
type VariantId = "soft" | "tail" | "outline";
type Message = {
id: string;
sender: Sender;
text: string;
time: string;
status?: Status;
};
const VARIANTS: { id: VariantId; label: string; hint: string }[] = [
{ id: "soft", label: "Soft", hint: "Evenly rounded pills on both sides" },
{ id: "tail", label: "Tail", hint: "Clipped corner pointing at the speaker" },
{ id: "outline", label: "Outline", hint: "Hairline borders, no heavy fills" },
];
const SEED: Message[] = [
{
id: "m1",
sender: "them",
text: "Morning! I pushed the rebuilt checkout to staging last night.",
time: "9:02 AM",
},
{
id: "m2",
sender: "me",
text: "Just opened it. The step indicator finally reads like a progress bar instead of a puzzle.",
time: "9:04 AM",
status: "read",
},
{
id: "m3",
sender: "them",
text: "That was the whole point of the rewrite. Four steps down to two.",
time: "9:04 AM",
},
{
id: "m4",
sender: "me",
text: "Did guest checkout make the cut?",
time: "9:05 AM",
status: "read",
},
{
id: "m5",
sender: "them",
text: "It did, and Apple Pay is wired up on mobile. Card entry is the fallback, not the default.",
time: "9:06 AM",
},
];
const REPLIES: string[] = [
"On it. Sending the link over in a second.",
"Good catch. I'll drop that into the ticket now.",
"Ha, that's exactly where I landed too.",
"Give me two minutes, the build is still running.",
"Done. Refresh staging and you'll see it.",
"Fair. Let's park that until after the release.",
];
const QUICK_REPLIES: string[] = [
"Looks great",
"Can you share the Figma?",
"Let's ship it",
];
function nowLabel(): string {
const d = new Date();
const h = d.getHours();
const m = d.getMinutes();
const suffix = h >= 12 ? "PM" : "AM";
const hour = h % 12 === 0 ? 12 : h % 12;
return `${hour}:${m.toString().padStart(2, "0")} ${suffix}`;
}
function bubbleClass(variant: VariantId, sender: Sender): string {
const base =
"relative max-w-[78%] px-4 py-2.5 text-[15px] leading-relaxed shadow-sm";
const radius =
variant === "tail"
? sender === "me"
? "rounded-2xl rounded-br-sm"
: "rounded-2xl rounded-bl-sm"
: "rounded-2xl";
let skin: string;
if (variant === "outline") {
skin =
sender === "me"
? "bg-indigo-50 text-indigo-900 ring-1 ring-inset ring-indigo-300 dark:bg-indigo-500/10 dark:text-indigo-50 dark:ring-indigo-400/50"
: "bg-transparent text-slate-700 ring-1 ring-inset ring-slate-300 dark:text-slate-200 dark:ring-slate-600";
} else {
skin =
sender === "me"
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-white text-slate-800 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-700";
}
return `${base} ${radius} ${skin}`;
}
function metaClass(variant: VariantId, sender: Sender): string {
if (sender === "them") return "text-slate-400 dark:text-slate-500";
return variant === "outline"
? "text-indigo-500 dark:text-indigo-300"
: "text-indigo-100/75";
}
function Ticks({ status, filled }: { status: Status; filled: boolean }) {
const read = status === "read";
const tone = read
? filled
? "text-sky-300"
: "text-sky-500 dark:text-sky-400"
: filled
? "text-indigo-100/70"
: "text-indigo-400 dark:text-indigo-300/70";
const label =
status === "sent" ? "Sent" : status === "delivered" ? "Delivered" : "Read";
return (
<span className={tone} title={label}>
<span className="sr-only">{label}</span>
<svg
viewBox="0 0 20 12"
width="17"
height="11"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="block"
>
{status === "sent" ? (
<path d="M5 6.5 L8.5 10 L16 2.5" />
) : (
<>
<path d="M1.5 6.5 L5 10 L12.5 2.5" />
<path d="M8.5 10 L16 2.5" />
</>
)}
</svg>
</span>
);
}
function Avatar() {
return (
<span
aria-hidden="true"
className="mb-4 grid h-8 w-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 text-[11px] font-semibold tracking-wide text-white shadow-sm"
>
AR
</span>
);
}
export default function ChatBubbles() {
const [messages, setMessages] = useState<Message[]>(SEED);
const [input, setInput] = useState<string>("");
const [variant, setVariant] = useState<VariantId>("tail");
const [typing, setTyping] = useState<boolean>(false);
const reduced = useReducedMotion();
const inputId = useId();
const scrollRef = useRef<HTMLDivElement | null>(null);
const idRef = useRef<number>(100);
const replyRef = useRef<number>(0);
const timers = useRef<number[]>([]);
useEffect(() => {
const held = timers.current;
return () => {
held.forEach((t) => window.clearTimeout(t));
};
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: reduced ? "auto" : "smooth",
});
}, [messages, typing, reduced]);
const send = (raw: string) => {
const text = raw.trim();
if (!text) return;
const mine: Message = {
id: `c${idRef.current++}`,
sender: "me",
text,
time: nowLabel(),
status: "sent",
};
setMessages((prev) => [...prev, mine]);
setInput("");
const t1 = window.setTimeout(() => {
setMessages((prev) =>
prev.map((m) =>
m.id === mine.id ? { ...m, status: "delivered" as const } : m,
),
);
setTyping(true);
}, 550);
const t2 = window.setTimeout(() => {
setTyping(false);
const reply: Message = {
id: `c${idRef.current++}`,
sender: "them",
text: REPLIES[replyRef.current % REPLIES.length],
time: nowLabel(),
};
replyRef.current += 1;
setMessages((prev) => [
...prev.map((m) =>
m.sender === "me" ? { ...m, status: "read" as const } : m,
),
reply,
]);
}, 2000);
timers.current.push(t1, t2);
};
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
send(input);
};
const rise = reduced
? {}
: {
initial: { opacity: 0, y: 10, scale: 0.97 },
animate: { opacity: 1, y: 0, scale: 1 },
transition: { duration: 0.26, ease: "easeOut" as const },
};
const fade = reduced
? {}
: {
initial: { opacity: 0, y: 6 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -4 },
transition: { duration: 0.2, ease: "easeOut" as const },
};
const ringBase =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500";
const ring = `${ringBase} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`;
const ringOnTrack = `${ringBase} focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800`;
const ringInset = `${ringBase} focus-visible:ring-inset`;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 lg:px-8 dark:bg-slate-950">
<style>{`
@keyframes cb-typing-dot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.35; }
30% { transform: translateY(-4px); opacity: 1; }
}
@keyframes cb-online-pulse {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
.cb-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 9999px;
background: currentColor;
animation: cb-typing-dot 1.25s infinite ease-in-out;
}
.cb-online-ring {
animation: cb-online-pulse 2.4s infinite ease-out;
}
@media (prefers-reduced-motion: reduce) {
.cb-dot, .cb-online-ring { animation: none !important; }
.cb-dot { opacity: 0.6; }
.cb-online-ring { opacity: 0; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Chat
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Message bubbles, both sides
</h2>
<p className="mt-2 max-w-lg text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Incoming and outgoing bubbles with timestamps, delivery ticks and a
live typing indicator. Switch the bubble treatment below, then send a
message to watch the thread respond.
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<span
id="cb-style-label"
className="text-xs font-medium text-slate-500 dark:text-slate-400"
>
Bubble style
</span>
<div
role="group"
aria-labelledby="cb-style-label"
className="inline-flex gap-1 rounded-full bg-slate-200/70 p-1 dark:bg-slate-800"
>
{VARIANTS.map((v) => {
const active = v.id === variant;
return (
<button
key={v.id}
type="button"
onClick={() => setVariant(v.id)}
aria-pressed={active}
title={v.hint}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${ringOnTrack} ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{v.label}
</button>
);
})}
</div>
</div>
<div className="mt-5 overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-5 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80">
<span className="relative">
<span
aria-hidden="true"
className="grid h-10 w-10 place-items-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 text-xs font-semibold tracking-wide text-white"
>
AR
</span>
<span className="absolute -bottom-0.5 -right-0.5 grid h-3.5 w-3.5 place-items-center">
<span className="cb-online-ring absolute inset-0 rounded-full bg-emerald-500" />
<span className="relative h-3 w-3 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900" />
</span>
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
Ava Reyes
</p>
<p className="text-xs text-emerald-600 dark:text-emerald-400">
{typing ? "typing…" : "Active now"}
</p>
</div>
<button
type="button"
aria-label="Conversation details"
className={`ml-auto grid h-9 w-9 place-items-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-200 ${ring}`}
>
<svg
viewBox="0 0 20 20"
width="18"
height="18"
fill="currentColor"
aria-hidden="true"
>
<circle cx="10" cy="4" r="1.6" />
<circle cx="10" cy="10" r="1.6" />
<circle cx="10" cy="16" r="1.6" />
</svg>
</button>
</header>
<div
ref={scrollRef}
tabIndex={0}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="Conversation with Ava Reyes"
className={`h-[26rem] overflow-y-auto bg-slate-50 px-4 py-5 sm:px-5 dark:bg-slate-950/40 ${ringInset}`}
>
<div className="mb-4 flex items-center gap-3">
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-800" />
<span className="rounded-full bg-slate-200/70 px-2.5 py-0.5 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
Today
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-800" />
</div>
<ul className="flex flex-col gap-3">
{messages.map((m) => {
const mine = m.sender === "me";
return (
<motion.li
key={m.id}
{...rise}
className={`flex items-end gap-2 ${mine ? "justify-end" : "justify-start"}`}
>
{!mine && <Avatar />}
<div className={bubbleClass(variant, m.sender)}>
<p className="whitespace-pre-wrap break-words">{m.text}</p>
<span
className={`mt-1 flex items-center gap-1 text-[11px] ${metaClass(variant, m.sender)} ${mine ? "justify-end" : "justify-start"}`}
>
<span>{m.time}</span>
{mine && m.status && (
<Ticks
status={m.status}
filled={variant !== "outline"}
/>
)}
</span>
</div>
</motion.li>
);
})}
<AnimatePresence initial={false}>
{typing && (
<motion.li
key="cb-typing"
{...fade}
className="flex items-end justify-start gap-2"
>
<Avatar />
<div
className={`${bubbleClass(variant, "them")} flex items-center gap-1.5 py-3 text-slate-400 dark:text-slate-500`}
>
<span className="sr-only">Ava Reyes is typing</span>
<span className="cb-dot" />
<span
className="cb-dot"
style={{ animationDelay: "0.15s" }}
/>
<span
className="cb-dot"
style={{ animationDelay: "0.3s" }}
/>
</div>
</motion.li>
)}
</AnimatePresence>
</ul>
</div>
<div className="border-t border-slate-200 bg-white px-4 py-3 sm:px-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex flex-wrap gap-2">
{QUICK_REPLIES.map((q) => (
<button
key={q}
type="button"
onClick={() => send(q)}
className={`rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-indigo-500/60 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-200 ${ring}`}
>
{q}
</button>
))}
</div>
<form onSubmit={onSubmit} className="flex items-center gap-2">
<label htmlFor={inputId} className="sr-only">
Write a message to Ava Reyes
</label>
<input
id={inputId}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
autoComplete="off"
placeholder="Write a message…"
className={`min-w-0 flex-1 rounded-full border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${ring}`}
/>
<button
type="submit"
disabled={input.trim().length === 0}
aria-label="Send message"
className={`grid h-10 w-10 shrink-0 place-items-center rounded-full bg-indigo-600 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:disabled:bg-slate-700 ${ring}`}
>
<svg
viewBox="0 0 20 20"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 10 L17 3 L12.5 17 L10 11 Z" />
</svg>
</button>
</form>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Support Widget
Originalfloating support chat widget

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

