shadimage
gaia
uichat-demosource: ok · hit

Chat Demo

Reusable platform-aware chat preview with pixel-accurate bubbles, headers and composers for iMessage, WhatsApp, Slack, Discord and Telegram. Designed to drop inside the iPhone Mockup.

npx shadcn add "https://ui.heygaia.io/r/chat-demo.json"
free

Where it goes

navbartestimonialchat

Visual style

retro

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

iphone-mockup

Files1 · source cached 10/15/2026, 2:50:53 AM

registry/new-york/ui/chat-demo.tsx
"use client";

import { cn } from "@/lib/utils";

export type ChatPlatform =
	| "imessage"
	| "whatsapp"
	| "slack"
	| "discord"
	| "telegram";

export interface ChatMessageItem {
	id?: string | number;
	from?: "me" | "them";
	text?: string;
	time?: string;
	author?: string;
	authorColor?: string;
	avatar?: string;
	status?: "sent" | "delivered" | "read";
	reactions?: { emoji: string; count: number }[];
	typing?: boolean;
}

export interface ChatDemoProps {
	platform: ChatPlatform;
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	headerAvatar?: string;
	showComposer?: boolean;
	theme?: "light" | "dark";
	className?: string;
}

const DEFAULT_AVATAR = "/gaia-glow.png";

const SF_STACK =
	'-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif';
const SLACK_STACK =
	'"Slack-Lato", "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
const DISCORD_STACK =
	'"gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif';

export function ChatDemo({
	platform,
	messages,
	title,
	subtitle,
	headerAvatar,
	showComposer = true,
	theme,
	className,
}: ChatDemoProps) {
	switch (platform) {
		case "imessage":
			return (
				<IMessageDemo
					messages={messages}
					title={title}
					subtitle={subtitle}
					headerAvatar={headerAvatar ?? DEFAULT_AVATAR}
					showComposer={showComposer}
					className={className}
				/>
			);
		case "whatsapp":
			return (
				<WhatsAppDemo
					messages={messages}
					title={title}
					subtitle={subtitle}
					headerAvatar={headerAvatar ?? DEFAULT_AVATAR}
					showComposer={showComposer}
					className={className}
				/>
			);
		case "slack":
			return (
				<SlackDemo
					messages={messages}
					title={title}
					subtitle={subtitle}
					showComposer={showComposer}
					theme={theme ?? "light"}
					className={className}
				/>
			);
		case "discord":
			return (
				<DiscordDemo
					messages={messages}
					title={title}
					subtitle={subtitle}
					showComposer={showComposer}
					className={className}
				/>
			);
		case "telegram":
			return (
				<TelegramDemo
					messages={messages}
					title={title}
					subtitle={subtitle}
					headerAvatar={headerAvatar ?? DEFAULT_AVATAR}
					showComposer={showComposer}
					className={className}
				/>
			);
	}
}

/* =========================================================================
 * Shared curved bubble (the iMessage shape, reused by WhatsApp & Telegram)
 * ========================================================================= */

const TAIL_THEM =
	"M 20 0 L 20 2 A 16 16 0 0 1 4 18 L 0 18 L 0 17.54 A 10 10 0 0 0 7 8 L 7 0 Z";
const TAIL_ME =
	"M 0 0 L 0 2 A 16 16 0 0 0 16 18 L 20 18 L 20 17.54 A 10 10 0 0 1 13 8 L 13 0 Z";

interface CurvedBubbleProps {
	from: "me" | "them";
	tail: boolean;
	background: string;
	tailColor: string;
	color: string;
	children: React.ReactNode;
	/** Inline meta (time / ticks) shown at bottom-right of the bubble */
	meta?: React.ReactNode;
	maxWidthPct?: number;
}

function CurvedBubble({
	from,
	tail,
	background,
	tailColor,
	color,
	children,
	meta,
	maxWidthPct = 78,
}: CurvedBubbleProps) {
	const isMe = from === "me";
	return (
		<div className="relative" style={{ maxWidth: `${maxWidthPct}%` }}>
			<div
				style={{
					background,
					color,
					padding: "7px 13px 8px",
					borderRadius: 18,
					fontSize: 15.5,
					lineHeight: "20px",
					letterSpacing: "-0.01em",
					wordBreak: "break-word",
					position: "relative",
					whiteSpace: "pre-wrap",
				}}
			>
				{children}
				{meta && (
					<span
						style={{
							display: "inline-block",
							verticalAlign: "bottom",
							marginLeft: 6,
							marginRight: -2,
							marginBottom: -1,
							fontSize: 11,
							lineHeight: "14px",
							letterSpacing: "-0.01em",
							whiteSpace: "nowrap",
							opacity: 0.95,
						}}
					>
						{meta}
					</span>
				)}
			</div>
			{tail && (
				<span
					aria-hidden="true"
					style={{
						position: "absolute",
						bottom: 0,
						[isMe ? "right" : "left"]: -7,
						width: 20,
						height: 18,
						background: tailColor,
						clipPath: `path('${isMe ? TAIL_ME : TAIL_THEM}')`,
					}}
				/>
			)}
		</div>
	);
}

type CurvedThread = {
	from: "me" | "them";
	items: ChatMessageItem[];
};

function curvedThread(messages: ChatMessageItem[]): CurvedThread[] {
	const out: CurvedThread[] = [];
	for (const m of messages) {
		const from = m.from ?? "them";
		const last = out[out.length - 1];
		if (last && last.from === from) last.items.push(m);
		else out.push({ from, items: [m] });
	}
	return out;
}

/* =========================================================================
 * iMessage
 * ========================================================================= */

const IMESSAGE_GRADIENT =
	"linear-gradient(180deg, #309BFE 0%, #027BFF 100%)";
const IMESSAGE_THEM_BG = "#E9E9EB";
const IMESSAGE_TAIL_ME_COLOR = "#0E89FF";

function IMessageDemo({
	messages,
	title,
	subtitle,
	headerAvatar,
	showComposer,
	className,
}: {
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	headerAvatar?: string;
	showComposer: boolean;
	className?: string;
}) {
	const grouped = curvedThread(messages);
	return (
		<div
			className={cn("flex h-full flex-col", className)}
			style={{ background: "#ffffff", fontFamily: SF_STACK, color: "#000" }}
		>
			<div
				className="flex shrink-0 flex-col items-center justify-center"
				style={{ background: "#ffffff", padding: "2px 16px 10px" }}
			>
				<div
					className="overflow-hidden rounded-full"
					style={{ width: 54, height: 54, marginBottom: 5 }}
				>
					{/* biome-ignore lint/performance/noImgElement: tiny remote avatar */}
					<img
						src={headerAvatar}
						alt=""
						style={{ width: 54, height: 54, objectFit: "cover" }}
					/>
				</div>
				<div
					className="flex items-center"
					style={{ fontSize: 14, color: "#000", fontWeight: 600, gap: 3 }}
				>
					<span style={{ letterSpacing: "-0.01em" }}>{title ?? "GAIA"}</span>
					<svg
						width="5"
						height="8"
						viewBox="0 0 7 11"
						fill="none"
						aria-hidden
					>
						<path
							d="M1 1l4.5 4.5L1 10"
							stroke="rgba(60,60,67,0.28)"
							strokeWidth="1.4"
							strokeLinecap="round"
							strokeLinejoin="round"
						/>
					</svg>
				</div>
				{subtitle && (
					<span style={{ fontSize: 11, color: "rgba(60,60,67,0.55)", marginTop: 2 }}>
						{subtitle}
					</span>
				)}
			</div>

			<div
				className="flex flex-1 flex-col overflow-y-auto px-3 pb-3"
				style={{ scrollbarWidth: "none", gap: 8 }}
			>
				{grouped.map((group, gi) => {
					const groupTime = group.items[0].time;
					return (
						<div key={gi} className="flex flex-col" style={{ gap: 8 }}>
							{groupTime && (
								<div
									className="self-center text-center"
									style={{
										fontSize: 11,
										color: "rgba(60,60,67,0.6)",
										fontWeight: 500,
										letterSpacing: "-0.01em",
										marginTop: gi === 0 ? 4 : 6,
									}}
								>
									{groupTime}
								</div>
							)}
							<div
								className={cn(
									"flex flex-col",
									group.from === "me" ? "items-end" : "items-start",
								)}
								style={{ gap: 2 }}
							>
								{group.items.map((m, i) => {
									const isLast = i === group.items.length - 1;
									const isMe = group.from === "me";
									return (
										<CurvedBubble
											key={m.id ?? `${gi}-${i}`}
											from={group.from}
											tail={isLast}
											background={isMe ? IMESSAGE_GRADIENT : IMESSAGE_THEM_BG}
											tailColor={
												isMe ? IMESSAGE_TAIL_ME_COLOR : IMESSAGE_THEM_BG
											}
											color={isMe ? "#fff" : "#000"}
										>
											{m.typing ? (
												<TypingDots
													color={isMe ? "rgba(255,255,255,0.9)" : "#8e8e93"}
												/>
											) : (
												m.text
											)}
										</CurvedBubble>
									);
								})}
							</div>
						</div>
					);
				})}
			</div>

			{showComposer && (
				<div
					className="flex shrink-0 items-end gap-2 px-2 pt-2 pb-1"
					style={{ background: "#ffffff" }}
				>
					<button
						type="button"
						aria-label="More"
						className="flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-[filter,background] duration-150 hover:brightness-95 active:brightness-90"
						style={{
							width: 30,
							height: 30,
							background: "#E9E9EB",
							color: "rgba(60,60,67,0.7)",
						}}
					>
						<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
							<path
								d="M7 1v12M1 7h12"
								stroke="currentColor"
								strokeWidth="1.6"
								strokeLinecap="round"
							/>
						</svg>
					</button>
					<div
						className="flex flex-1 items-center justify-between"
						style={{
							border: "1px solid rgba(60,60,67,0.18)",
							borderRadius: 18,
							padding: "5px 8px 5px 12px",
							background: "#fff",
							minHeight: 32,
						}}
					>
						<input
							type="text"
							placeholder="iMessage"
							className="chat-demo-input min-w-0 flex-1 border-0 bg-transparent p-0 outline-none placeholder:text-[rgba(60,60,67,0.5)]"
							style={{
								fontSize: 15,
								color: "#000",
								letterSpacing: "-0.01em",
								fontFamily: "inherit",
							}}
						/>
						<button
							type="button"
							aria-label="Audio"
							className="ml-2 flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
							style={{ color: "rgba(60,60,67,0.7)", width: 22, height: 22 }}
						>
							<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden>
								<path
									d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 0 0 4 0v-4a2 2 0 0 0-2-2Zm4 6a4 4 0 0 1-8 0H3a5 5 0 0 0 4.5 5V14h1v-1.5A5 5 0 0 0 13 7.5h-1Z"
									fill="currentColor"
								/>
							</svg>
						</button>
					</div>
				</div>
			)}
		</div>
	);
}

/* =========================================================================
 * WhatsApp — iMessage bubble shape + WhatsApp SVG colors and chrome.
 * Source: .context/attachments/WhatsApp Chat.svg
 * ========================================================================= */

function WhatsAppDemo({
	messages,
	title,
	subtitle,
	headerAvatar,
	showComposer,
	className,
}: {
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	headerAvatar?: string;
	showComposer: boolean;
	className?: string;
}) {
	// Palette extracted from WhatsApp Chat.svg
	const bg = "#EFEFF4";
	const chromeBg = "#F6F6F6";
	const myBubble = "#DCF7C5";
	const theirBubble = "#FFFFFF";
	const textColor = "#060606";
	const metaColor = "rgba(0,0,0,0.45)";
	const accent = "#007AFF";

	const grouped = curvedThread(messages);

	return (
		<div
			className={cn("flex h-full flex-col", className)}
			style={{ background: bg, fontFamily: SF_STACK, color: textColor }}
		>
			{/* Header — iOS chrome from WhatsApp Chat.svg (#F6F6F6) */}
			<div className="flex shrink-0 flex-col" style={{ background: chromeBg }}>
				<div
					className="grid items-center"
					style={{
						gridTemplateColumns: "1fr auto 1fr",
						padding: "6px 12px 8px",
						gap: 8,
					}}
				>
					<div className="flex items-center" style={{ color: accent }}>
						<svg width="12" height="20" viewBox="0 0 12 20" aria-hidden fill="none">
							<path
								d="M10 2 2 10l8 8"
								stroke={accent}
								strokeWidth="2.4"
								strokeLinecap="round"
								strokeLinejoin="round"
							/>
						</svg>
					</div>
					<div className="flex flex-col items-center" style={{ minWidth: 0 }}>
						<div
							className="overflow-hidden rounded-full"
							style={{ width: 32, height: 32 }}
						>
							{/* biome-ignore lint/performance/noImgElement: avatar */}
							<img
								src={headerAvatar}
								alt=""
								style={{ width: 32, height: 32, objectFit: "cover" }}
							/>
						</div>
						<span
							style={{
								fontSize: 10,
								marginTop: 2,
								color: textColor,
								letterSpacing: "-0.01em",
								maxWidth: 140,
								overflow: "hidden",
								textOverflow: "ellipsis",
								whiteSpace: "nowrap",
							}}
						>
							{title ?? "GAIA"}
						</span>
					</div>
					<div
						className="flex items-center justify-end"
						style={{ color: accent }}
					>
						<svg width="22" height="14" viewBox="0 0 22 14" aria-hidden fill="none">
							<rect x="0.5" y="0.5" width="15" height="13" rx="3" stroke={accent} />
							<path
								d="M16 4l5 -3v12l-5 -3z"
								fill={accent}
								stroke={accent}
								strokeWidth="0.5"
								strokeLinejoin="round"
							/>
						</svg>
					</div>
				</div>
				<div style={{ height: 0.5, background: "rgba(60,60,67,0.18)" }} />
			</div>

			{/* Chat area — light gray with the WhatsApp doodle pattern */}
			<div
				className="flex flex-1 flex-col overflow-y-auto px-3 pb-3"
				style={{
					scrollbarWidth: "none",
					gap: 8,
					backgroundColor: bg,
					backgroundImage: 'url("/whatsapp-doodle.png")',
					backgroundRepeat: "repeat",
					backgroundSize: "404px auto",
					paddingTop: 8,
				}}
			>
				{grouped.map((group, gi) => {
					const isMe = group.from === "me";
					return (
						<div
							key={gi}
							className={cn(
								"flex flex-col",
								isMe ? "items-end" : "items-start",
							)}
							style={{ gap: 2 }}
						>
							{group.items.map((m, i) => {
								const isLast = i === group.items.length - 1;
								const showMeta = !m.typing && (m.time || (isMe && m.status));
								return (
									<CurvedBubble
										key={m.id ?? `${gi}-${i}`}
										from={group.from}
										tail={isLast}
										background={isMe ? myBubble : theirBubble}
										tailColor={isMe ? myBubble : theirBubble}
										color={textColor}
										meta={
											showMeta ? (
												<span
													style={{
														color: metaColor,
														display: "inline-flex",
														alignItems: "center",
														gap: 3,
													}}
												>
													{m.time ?? ""}
													{isMe && m.status && (
														<WhatsAppTicks status={m.status} />
													)}
												</span>
											) : undefined
										}
									>
										{m.typing ? (
											<TypingDots color={metaColor} />
										) : (
											m.text
										)}
									</CurvedBubble>
								);
							})}
						</div>
					);
				})}
			</div>

			{showComposer && (
				<div
					className="flex shrink-0 items-center gap-2 px-2 pt-2 pb-1.5"
					style={{ background: chromeBg }}
				>
					<button
						type="button"
						aria-label="Camera"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 32, height: 32, color: "#3C3C43" }}
					>
						<CameraIcon />
					</button>
					<div
						className="flex flex-1 items-center justify-between gap-2"
						style={{
							background: "#FFFFFF",
							border: "0.5px solid #8E8E93",
							borderRadius: 16,
							padding: "0 10px",
							height: 32,
							color: "#8E8E93",
						}}
					>
						<input
							type="text"
							placeholder="Message"
							className="chat-demo-input min-w-0 flex-1 border-0 bg-transparent p-0 outline-none placeholder:text-[#8E8E93]"
							style={{
								fontSize: 15,
								color: "#000",
								letterSpacing: "-0.01em",
								fontFamily: "inherit",
							}}
						/>
						<button
							type="button"
							aria-label="Emoji"
							className="flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
							style={{ color: "#8E8E93", width: 22, height: 22 }}
						>
							<EmojiIcon size={18} />
						</button>
					</div>
					<button
						type="button"
						aria-label="Voice message"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 32, height: 32, color: "#3C3C43" }}
					>
						<MicIcon />
					</button>
				</div>
			)}
		</div>
	);
}

function WhatsAppTicks({ status }: { status: "sent" | "delivered" | "read" }) {
	const color = status === "read" ? "#3497F9" : "rgba(0,0,0,0.4)";
	if (status === "sent") {
		return (
			<svg width="14" height="11" viewBox="0 0 14 11" aria-hidden fill="none">
				<path
					d="M1 6l3.5 3.5L11 2"
					stroke={color}
					strokeWidth="1.6"
					strokeLinecap="round"
					strokeLinejoin="round"
				/>
			</svg>
		);
	}
	// TickDouble02-style: two clean overlapping check marks
	return (
		<svg width="16" height="11" viewBox="0 0 18 14" aria-hidden fill="none">
			<path
				d="M1 7l3.2 3.5L11 3.5"
				stroke={color}
				strokeWidth="1.7"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
			<path
				d="M6 7l3.2 3.5L17 3.5"
				stroke={color}
				strokeWidth="1.7"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
		</svg>
	);
}

/* =========================================================================
 * Telegram — iMessage bubble shape + Telegram SVG palette and chrome.
 * Source: .context/attachments/Telegram Chat.svg
 * ========================================================================= */

function TelegramDemo({
	messages,
	title,
	subtitle,
	headerAvatar,
	showComposer,
	className,
}: {
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	headerAvatar?: string;
	showComposer: boolean;
	className?: string;
}) {
	// Palette extracted from Telegram Chat.svg
	const chromeBg = "#F6F6F6";
	const blueOverlay = "#2B78CD"; // 50% over the doodle pattern
	const myBubble = "#E1FEC6";
	const theirBubble = "#FFFFFF";
	const textColor = "#060606";
	const metaColor = "#858E99";
	const myMeta = "#3EAA3C";
	const accent = "#037EE5";

	const grouped = curvedThread(messages);

	return (
		<div
			className={cn("flex h-full flex-col", className)}
			style={{ fontFamily: SF_STACK, color: textColor, background: chromeBg }}
		>
			{/* iOS header */}
			<div className="flex shrink-0 flex-col" style={{ background: chromeBg }}>
				<div
					className="grid items-center"
					style={{
						gridTemplateColumns: "1fr auto 1fr",
						padding: "6px 12px 8px",
						gap: 8,
					}}
				>
					<div className="flex items-center" style={{ color: accent }}>
						<svg
							width="12"
							height="20"
							viewBox="0 0 12 20"
							aria-hidden
							fill="none"
						>
							<path
								d="M10 2 2 10l8 8"
								stroke={accent}
								strokeWidth="2.4"
								strokeLinecap="round"
								strokeLinejoin="round"
							/>
						</svg>
						<span
							style={{ fontSize: 17, marginLeft: 8, letterSpacing: "-0.01em" }}
						>
							Back
						</span>
					</div>
					<div className="flex flex-col items-center" style={{ minWidth: 0 }}>
						<span
							style={{
								fontSize: 17,
								fontWeight: 600,
								letterSpacing: "-0.01em",
								color: textColor,
								maxWidth: 160,
								overflow: "hidden",
								textOverflow: "ellipsis",
								whiteSpace: "nowrap",
							}}
						>
							{title ?? "GAIA"}
						</span>
						<span style={{ fontSize: 12, color: metaColor, marginTop: 1 }}>
							{subtitle ?? "last seen recently"}
						</span>
					</div>
					<div
						className="flex items-center justify-end"
						style={{ color: accent }}
					>
						<div
							className="overflow-hidden rounded-full"
							style={{ width: 32, height: 32 }}
						>
							{/* biome-ignore lint/performance/noImgElement: avatar */}
							<img
								src={headerAvatar}
								alt=""
								style={{ width: 32, height: 32, objectFit: "cover" }}
							/>
						</div>
					</div>
				</div>
				<div style={{ height: 0.5, background: "rgba(60,60,67,0.18)" }} />
			</div>

			{/* Chat area: blue overlay + Telegram doodle pattern */}
			<div
				className="flex flex-1 flex-col overflow-y-auto px-3 pb-3"
				style={{
					scrollbarWidth: "none",
					gap: 8,
					backgroundColor: blueOverlay,
					backgroundImage:
						'linear-gradient(rgba(43,120,205,0.5), rgba(43,120,205,0.5)), url("/telegram-doodle.png")',
					backgroundSize: "auto, 480px auto",
					backgroundRepeat: "repeat",
					paddingTop: 8,
				}}
			>
				{grouped.map((group, gi) => {
					const isMe = group.from === "me";
					return (
						<div
							key={gi}
							className={cn(
								"flex flex-col",
								isMe ? "items-end" : "items-start",
							)}
							style={{ gap: 2 }}
						>
							{group.items.map((m, i) => {
								const isLast = i === group.items.length - 1;
								const showMeta = !m.typing && (m.time || (isMe && m.status));
								return (
									<CurvedBubble
										key={m.id ?? `${gi}-${i}`}
										from={group.from}
										tail={isLast}
										background={isMe ? myBubble : theirBubble}
										tailColor={isMe ? myBubble : theirBubble}
										color={textColor}
										meta={
											showMeta ? (
												<span
													style={{
														color: isMe ? myMeta : metaColor,
														display: "inline-flex",
														alignItems: "center",
														gap: 3,
													}}
												>
													{m.time ?? ""}
													{isMe && m.status && (
														<TelegramTicks
															status={m.status}
															color={myMeta}
														/>
													)}
												</span>
											) : undefined
										}
									>
										{m.typing ? (
											<TypingDots color={isMe ? myMeta : metaColor} />
										) : (
											m.text
										)}
									</CurvedBubble>
								);
							})}
						</div>
					);
				})}
			</div>

			{showComposer && (
				<div
					className="flex shrink-0 items-center gap-2 px-2 pt-2 pb-1.5"
					style={{ background: chromeBg }}
				>
					<button
						type="button"
						aria-label="Attach"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 30, height: 30, color: accent }}
					>
						<AttachmentIcon />
					</button>
					<div
						className="flex flex-1 items-center justify-between gap-2"
						style={{
							background: "#FFFFFF",
							border: "0.5px solid #D1D1D6",
							borderRadius: 16,
							padding: "0 12px",
							height: 32,
							color: metaColor,
						}}
					>
						<input
							type="text"
							placeholder="Message"
							className="chat-demo-input min-w-0 flex-1 border-0 bg-transparent p-0 outline-none placeholder:text-[#858E99]"
							style={{
								fontSize: 15,
								color: "#000",
								letterSpacing: "-0.01em",
								fontFamily: "inherit",
							}}
						/>
						<button
							type="button"
							aria-label="Emoji"
							className="flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
							style={{ color: metaColor, width: 22, height: 22 }}
						>
							<EmojiIcon size={18} />
						</button>
					</div>
					<button
						type="button"
						aria-label="Voice"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 30, height: 30, color: accent }}
					>
						<MicIcon />
					</button>
				</div>
			)}
		</div>
	);
}

function TelegramTicks({
	status,
	color,
}: {
	status: "sent" | "delivered" | "read";
	color: string;
}) {
	if (status === "sent") {
		return (
			<svg width="14" height="11" viewBox="0 0 14 11" aria-hidden fill="none">
				<path
					d="M1 6l3.5 3.5L11 2"
					stroke={color}
					strokeWidth="1.4"
					strokeLinecap="round"
					strokeLinejoin="round"
				/>
			</svg>
		);
	}
	return (
		<svg width="16" height="11" viewBox="0 0 16 11" aria-hidden fill="none">
			<path
				d="M0.5 6.5l3.5 3.5L10.5 2.5"
				stroke={color}
				strokeWidth="1.4"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
			<path
				d="M4 6.5l3.5 3.5L13.5 2.5"
				stroke={color}
				strokeWidth="1.4"
				strokeLinecap="round"
				strokeLinejoin="round"
			/>
		</svg>
	);
}

/* =========================================================================
 * Slack
 * ========================================================================= */

function SlackDemo({
	messages,
	title,
	subtitle,
	showComposer,
	theme,
	className,
}: {
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	showComposer: boolean;
	theme: "light" | "dark";
	className?: string;
}) {
	const isDark = theme === "dark";
	const bg = isDark ? "#1A1D21" : "#FFFFFF";
	const fg = isDark ? "#D1D2D3" : "#1D1C1D";
	const muted = isDark ? "#ABABAD" : "#616061";
	const headerBorder = isDark ? "#2F3236" : "#E8E8E8";

	const groups = groupByAuthor(messages);

	return (
		<div
			className={cn("flex h-full flex-col", className)}
			style={{ background: bg, color: fg, fontFamily: SLACK_STACK }}
		>
			<div
				className="flex shrink-0 items-center justify-between border-b px-4"
				style={{ borderColor: headerBorder, height: 56 }}
			>
				<div className="flex flex-col leading-tight">
					<div
						className="flex items-center gap-1"
						style={{ fontWeight: 700, fontSize: 16 }}
					>
						<span style={{ color: muted, fontWeight: 400, marginRight: 2 }}>
							#
						</span>
						{title ?? "general"}
						<svg
							width="12"
							height="12"
							viewBox="0 0 20 20"
							aria-hidden
							style={{ marginLeft: 4 }}
						>
							<path
								d="M5 8l5 5 5-5"
								fill="none"
								stroke={fg}
								strokeWidth="1.8"
								strokeLinecap="round"
								strokeLinejoin="round"
							/>
						</svg>
					</div>
					<span style={{ fontSize: 12, color: muted }}>
						{subtitle ?? "Add a topic"}
					</span>
				</div>
				<div className="flex items-center gap-2" style={{ color: muted }}>
					<button
						type="button"
						aria-label="Activity"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 32, height: 32 }}
					>
						<svg
							width="22"
							height="22"
							viewBox="0 0 24 24"
							aria-hidden
							fill="currentColor"
						>
							<path d="M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18Zm0-2a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm-1-10h2v4h3v2h-5V9Z" />
						</svg>
					</button>
					<button
						type="button"
						aria-label="Search"
						className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-black/[0.06]"
						style={{ width: 32, height: 32 }}
					>
						<svg
							width="22"
							height="22"
							viewBox="0 0 24 24"
							aria-hidden
							fill="none"
							stroke="currentColor"
							strokeWidth="2"
						>
							<circle cx="11" cy="11" r="7" />
							<path d="m20 20-4-4" strokeLinecap="round" />
						</svg>
					</button>
				</div>
			</div>

			<div
				className="flex flex-1 flex-col overflow-y-auto py-3"
				style={{ scrollbarWidth: "none", gap: 12 }}
			>
				{groups.map((g, gi) => (
					<div
						key={gi}
						className="flex items-start gap-2"
						style={{ padding: "0 16px" }}
					>
						<div
							className="shrink-0 overflow-hidden"
							style={{
								width: 36,
								height: 36,
								borderRadius: 6,
								marginTop: 4,
								background: g.author?.avatar
									? undefined
									: pickColor(g.author?.name ?? ""),
							}}
						>
							{/* biome-ignore lint/performance/noImgElement: avatar */}
							<img
								src={g.author?.avatar ?? DEFAULT_AVATAR}
								alt=""
								style={{ width: 36, height: 36, objectFit: "cover" }}
							/>
						</div>
						<div className="flex min-w-0 flex-1 flex-col">
							<div className="flex items-baseline gap-2">
								<span
									style={{
										fontWeight: 900,
										fontSize: 15,
										color: fg,
										letterSpacing: "-0.01em",
									}}
								>
									{g.author?.name ?? "Unknown"}
								</span>
								<span style={{ fontSize: 12, color: muted }}>
									{g.items[0].time ?? ""}
								</span>
							</div>
							<div className="flex flex-col" style={{ gap: 2 }}>
								{g.items.map((m, i) => (
									<div
										key={m.id ?? `${gi}-${i}`}
										style={{
											fontSize: 15,
											lineHeight: "22px",
											color: fg,
											wordBreak: "break-word",
											whiteSpace: "pre-wrap",
										}}
									>
										{m.typing ? (
											<TypingDots color={muted} />
										) : (
											renderSlackText(m.text ?? "", isDark)
										)}
									</div>
								))}
							</div>
							{g.items.some((m) => m.reactions?.length) && (
								<div className="mt-1 flex flex-wrap gap-1">
									{g.items.flatMap((m) =>
										(m.reactions ?? []).map((r, ri) => (
											<span
												key={`${ri}-${r.emoji}`}
												className="inline-flex items-center gap-1"
												style={{
													padding: "1px 7px",
													borderRadius: 12,
													border: `1px solid ${isDark ? "#3a3d42" : "#DDDDDD"}`,
													background: isDark ? "#26282C" : "#F1F4F7",
													fontSize: 12,
													fontWeight: 700,
													color: isDark ? "#9DB0CA" : "#1264A3",
												}}
											>
												<span style={{ fontSize: 13 }}>{r.emoji}</span>
												{r.count}
											</span>
										)),
									)}
								</div>
							)}
						</div>
					</div>
				))}
			</div>

			{showComposer && (
				<div className="shrink-0 px-3 pt-2 pb-1.5">
					<div
						className="flex flex-col overflow-hidden"
						style={{
							border: `1px solid ${isDark ? "#565856" : "#BABBBC"}`,
							borderRadius: 8,
							background: isDark ? "#222529" : "#fff",
						}}
					>
						{/* Formatting toolbar */}
						<div
							className="flex items-center"
							style={{
								height: 32,
								padding: "0 6px",
								color: muted,
								gap: 2,
								borderBottom: `1px solid ${isDark ? "#3a3d42" : "#E8E8E8"}`,
							}}
						>
							<SlackToolbarButton label="Bold">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M4 2.75A.75.75 0 0 1 4.75 2h6.343a3.91 3.91 0 0 1 3.88 3.449A2 2 0 0 1 15 5.84l.001.067a3.9 3.9 0 0 1-1.551 3.118A4.627 4.627 0 0 1 11.875 18H4.75a.75.75 0 0 1-.75-.75V9.5a.8.8 0 0 1 .032-.218A.8.8 0 0 1 4 9.065zm2.5 5.565h3.593a2.157 2.157 0 1 0 0-4.315H6.5zm4.25 1.935H6.5v5.5h4.25a2.75 2.75 0 1 0 0-5.5"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Italic">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M7 2.75A.75.75 0 0 1 7.75 2h7.5a.75.75 0 0 1 0 1.5H12.3l-2.6 13h2.55a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1 0-1.5H7.7l2.6-13H7.75A.75.75 0 0 1 7 2.75"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Underline">
								<path
									fill="currentColor"
									d="M17.25 17.12a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5zM14.5 1.63a.75.75 0 0 1 .75.75v8a5.25 5.25 0 1 1-10.5 0v-8a.75.75 0 0 1 1.5 0v8a3.75 3.75 0 0 0 7.5 0v-8a.75.75 0 0 1 .75-.75"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Strike">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M11.721 3.84c-.91-.334-2.028-.36-3.035-.114-1.51.407-2.379 1.861-2.164 3.15C6.718 8.051 7.939 9.5 11.5 9.5l.027.001h5.723a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5h3.66c-.76-.649-1.216-1.468-1.368-2.377-.347-2.084 1.033-4.253 3.265-4.848l.007-.002.007-.002c1.252-.307 2.68-.292 3.915.16 1.252.457 2.337 1.381 2.738 2.874a.75.75 0 0 1-1.448.39c-.25-.925-.91-1.528-1.805-1.856m2.968 9.114a.75.75 0 1 0-1.378.59c.273.64.186 1.205-.13 1.674-.333.492-.958.925-1.82 1.137-.989.243-1.991.165-3.029-.124-.93-.26-1.613-.935-1.858-1.845a.75.75 0 0 0-1.448.39c.388 1.441 1.483 2.503 2.903 2.9 1.213.338 2.486.456 3.79.135 1.14-.28 2.12-.889 2.704-1.753.6-.888.743-1.992.266-3.104"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarSeparator dark={isDark} />
							<SlackToolbarButton label="Link">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M12.306 3.756a2.75 2.75 0 0 1 3.889 0l.05.05a2.75 2.75 0 0 1 0 3.889l-3.18 3.18a2.75 2.75 0 0 1-3.98-.095l-.03-.034a.75.75 0 0 0-1.11 1.009l.03.034a4.25 4.25 0 0 0 6.15.146l3.18-3.18a4.25 4.25 0 0 0 0-6.01l-.05-.05a4.25 4.25 0 0 0-6.01 0L9.47 4.47a.75.75 0 1 0 1.06 1.06zm-4.611 12.49a2.75 2.75 0 0 1-3.89 0l-.05-.051a2.75 2.75 0 0 1 0-3.89l3.18-3.179a2.75 2.75 0 0 1 3.98.095l.03.034a.75.75 0 1 0 1.11-1.01l-.03-.033a4.25 4.25 0 0 0-6.15-.146l-3.18 3.18a4.25 4.25 0 0 0 0 6.01l.05.05a4.25 4.25 0 0 0 6.01 0l1.775-1.775a.75.75 0 0 0-1.06-1.06z"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Ordered list">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M3.792 2.094A.5.5 0 0 1 4 2.5V6h1a.5.5 0 1 1 0 1H2a.5.5 0 1 1 0-1h1V3.194l-.842.28a.5.5 0 0 1-.316-.948l1.5-.5a.5.5 0 0 1 .45.068M7.75 3.5a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM7 10.75a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m0 6.5a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10a.75.75 0 0 1-.75-.75m-4.293-3.36a1 1 0 0 1 .793-.39c.49 0 .75.38.75.75 0 .064-.033.194-.173.409a5 5 0 0 1-.594.711c-.256.267-.552.548-.87.848l-.088.084a42 42 0 0 0-.879.845A.5.5 0 0 0 2 18h3a.5.5 0 0 0 0-1H3.242l.058-.055c.316-.298.629-.595.904-.882a6 6 0 0 0 .711-.859c.18-.277.335-.604.335-.954 0-.787-.582-1.75-1.75-1.75a2 2 0 0 0-1.81 1.147.5.5 0 1 0 .905.427 1 1 0 0 1 .112-.184"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Bullet list">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M4 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a.75.75 0 0 1 .75-.75h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 7 3m.75 6.25a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zm0 7a.75.75 0 0 0 0 1.5h10a.75.75 0 0 0 0-1.5zM3 11a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarSeparator dark={isDark} />
							<SlackToolbarButton label="Quote">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M3.5 2.75a.75.75 0 0 0-1.5 0v14.5a.75.75 0 0 0 1.5 0zM6.75 3a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5zM6 10.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H6.75a.75.75 0 0 1-.75-.75m.75 5.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Code">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M12.058 3.212c.396.12.62.54.5.936L8.87 16.29a.75.75 0 1 1-1.435-.436l3.686-12.143a.75.75 0 0 1 .936-.5M5.472 6.24a.75.75 0 0 1 .005 1.06l-2.67 2.693 2.67 2.691a.75.75 0 1 1-1.065 1.057l-3.194-3.22a.75.75 0 0 1 0-1.056l3.194-3.22a.75.75 0 0 1 1.06-.005m9.044 1.06a.75.75 0 1 1 1.065-1.056l3.194 3.221a.75.75 0 0 1 0 1.057l-3.194 3.219a.75.75 0 0 1-1.065-1.057l2.67-2.69z"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
							<SlackToolbarButton label="Code block">
								<path
									fill="currentColor"
									fillRule="evenodd"
									d="M9.212 2.737a.75.75 0 1 0-1.424-.474l-2.5 7.5a.75.75 0 0 0 1.424.474zm6.038.265a.75.75 0 0 0 0 1.5h2a.25.25 0 0 1 .25.25v11.5a.25.25 0 0 1-.25.25h-13a.25.25 0 0 1-.25-.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .966.784 1.75 1.75 1.75h13a1.75 1.75 0 0 0 1.75-1.75v-11.5a1.75 1.75 0 0 0-1.75-1.75zm-3.69.5a.75.75 0 1 0-1.12.996l1.556 1.754-1.556 1.75a.75.75 0 1 0 1.12.997l2-2.249a.75.75 0 0 0 0-.996zM3.999 9.061a.75.75 0 0 1-1.058-.062l-2-2.249a.75.75 0 0 1 0-.996l2-2.252a.75.75 0 1 1 1.12.996L2.504 6.252l1.557 1.75a.75.75 0 0 1-.062 1.059"
									clipRule="evenodd"
								/>
							</SlackToolbarButton>
						</div>

						{/* Text input area */}
						<textarea
							rows={1}
							placeholder={`Message #${title ?? "general"}`}
							className={cn(
								"chat-demo-input resize-none border-0 bg-transparent outline-none",
								isDark
									? "placeholder:text-[#ABABAD]"
									: "placeholder:text-[#616061]",
							)}
							style={{
								padding: "10px 12px",
								fontSize: 15,
								color: fg,
								minHeight: 44,
								lineHeight: "20px",
								fontFamily: "inherit",
							}}
						/>

						{/* Footer toolbar */}
						<div
							className="flex items-center justify-between"
							style={{ padding: "0 6px 6px", color: muted }}
						>
							<div className="flex items-center gap-1">
								<SlackToolbarButton label="Attach">
									<path
										fill="currentColor"
										fillRule="evenodd"
										d="M10.75 3.25a.75.75 0 0 0-1.5 0v6H3.251L3.25 10v-.75a.75.75 0 0 0 0 1.5V10v.75h6v6a.75.75 0 0 0 1.5 0v-6h6a.75.75 0 0 0 0-1.5h-6z"
										clipRule="evenodd"
									/>
								</SlackToolbarButton>
								<SlackToolbarButton label="Formatting">
									<path
										fill="currentColor"
										fillRule="evenodd"
										d="M6.941 3.952c-.459-1.378-2.414-1.363-2.853.022l-4.053 12.8a.75.75 0 0 0 1.43.452l1.101-3.476h6.06l1.163 3.487a.75.75 0 1 0 1.423-.474zm1.185 8.298L5.518 4.427 3.041 12.25zm6.198-5.537a4.74 4.74 0 0 1 3.037-.081A3.74 3.74 0 0 1 20 10.208V17a.75.75 0 0 1-1.5 0v-.745a8 8 0 0 1-2.847 1.355 3 3 0 0 1-3.15-1.143C10.848 14.192 12.473 11 15.287 11H18.5v-.792c0-.984-.641-1.853-1.581-2.143a3.24 3.24 0 0 0-2.077.056l-.242.089a2.22 2.22 0 0 0-1.34 1.382l-.048.145a.75.75 0 0 1-1.423-.474l.048-.145a3.72 3.72 0 0 1 2.244-2.315zM18.5 12.5h-3.213c-1.587 0-2.504 1.801-1.57 3.085.357.491.98.717 1.572.57a6.5 6.5 0 0 0 2.47-1.223l.741-.593z"
										clipRule="evenodd"
									/>
								</SlackToolbarButton>
								<SlackToolbarButton label="Emoji">
									<path
										fill="currentColor"
										fillRule="evenodd"
										d="M2.5 10a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0M10 1a9 9 0 1 0 0 18 9 9 0 0 0 0-18M7.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M14 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-6.385 3.766a.75.75 0 1 0-1.425.468C6.796 14.08 8.428 15 10.027 15s3.23-.92 3.838-2.766a.75.75 0 1 0-1.425-.468c-.38 1.155-1.38 1.734-2.413 1.734s-2.032-.58-2.412-1.734"
										clipRule="evenodd"
									/>
								</SlackToolbarButton>
								<SlackToolbarButton label="Mention">
									<path
										fill="currentColor"
										fillRule="evenodd"
										d="M2.5 10a7.5 7.5 0 1 1 15 0v.645c0 1.024-.83 1.855-1.855 1.855a1.145 1.145 0 0 1-1.145-1.145V6.75a.75.75 0 0 0-1.494-.098 4.5 4.5 0 1 0 .465 6.212A2.64 2.64 0 0 0 15.646 14 3.355 3.355 0 0 0 19 10.645V10a9 9 0 1 0-3.815 7.357.75.75 0 1 0-.865-1.225A7.5 7.5 0 0 1 2.5 10m7.5 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6"
										clipRule="evenodd"
									/>
								</SlackToolbarButton>
								<SlackToolbarButton label="More options">
									<path
										fill="currentColor"
										d="M14.5 10a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0m-6.25 0a1.75 1.75 0 1 1 3.5 0 1.75 1.75 0 0 1-3.5 0M2 10a1.75 1.75 0 1 1 3.5 0A1.75 1.75 0 0 1 2 10"
									/>
								</SlackToolbarButton>
							</div>
							<div
								className="flex items-stretch overflow-hidden"
								style={{
									borderRadius: 6,
									border: `1px solid ${isDark ? "#3a3d42" : "#E1E1E1"}`,
									color: muted,
								}}
							>
								<button
									type="button"
									aria-label="Send"
									className="flex cursor-pointer items-center justify-center transition-colors hover:bg-black/[0.06] dark:hover:bg-white/[0.08]"
									style={{ width: 24, height: 22, color: muted }}
								>
									<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden>
										<path
											fill="currentColor"
											d="M1.5 2.106c0-.462.498-.754.901-.528l15.7 7.714a.73.73 0 0 1 .006 1.307L2.501 18.46l-.07.017a.754.754 0 0 1-.931-.733v-4.572c0-1.22.971-2.246 2.213-2.268l6.547-.17c.27-.01.75-.243.75-.797 0-.553-.5-.795-.75-.795l-6.547-.171C2.47 8.95 1.5 7.924 1.5 6.704z"
										/>
									</svg>
								</button>
								<div style={{ width: 1, background: isDark ? "#3a3d42" : "#E1E1E1" }} />
								<button
									type="button"
									aria-label="Schedule"
									className="flex cursor-pointer items-center justify-center transition-colors hover:bg-black/[0.06] dark:hover:bg-white/[0.08]"
									style={{ width: 20, height: 22, color: muted }}
								>
									<svg width="12" height="12" viewBox="0 0 20 20" aria-hidden>
										<path
											fill="currentColor"
											fillRule="evenodd"
											d="M5.72 7.47a.75.75 0 0 1 1.06 0L10 10.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-3.75 3.75a.75.75 0 0 1-1.06 0L5.72 8.53a.75.75 0 0 1 0-1.06"
											clipRule="evenodd"
										/>
									</svg>
								</button>
							</div>
						</div>
					</div>
				</div>
			)}
		</div>
	);
}

function SlackToolbarButton({
	label,
	children,
}: {
	label: string;
	children: React.ReactNode;
}) {
	return (
		<button
			type="button"
			aria-label={label}
			className="flex cursor-pointer items-center justify-center rounded transition-colors hover:bg-black/[0.06] dark:hover:bg-white/[0.08]"
			style={{ width: 26, height: 24 }}
		>
			<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden>
				{children}
			</svg>
		</button>
	);
}

function SlackToolbarSeparator({ dark }: { dark: boolean }) {
	return (
		<span
			aria-hidden
			style={{
				display: "inline-block",
				width: 1,
				height: 16,
				margin: "0 4px",
				background: dark ? "#3a3d42" : "#E1E1E1",
			}}
		/>
	);
}

function renderSlackText(text: string, dark: boolean) {
	const parts = text.split(/(@\w+|#\w+|`[^`]+`)/g);
	return parts.map((p, i) => {
		if (/^@\w+/.test(p)) {
			return (
				<span
					key={i}
					style={{
						background: dark ? "rgba(29,155,209,0.18)" : "#E8F5FA",
						color: dark ? "#1D9BD1" : "#1264A3",
						padding: "1px 3px",
						borderRadius: 3,
						fontWeight: 600,
					}}
				>
					{p}
				</span>
			);
		}
		if (/^#\w+/.test(p)) {
			return (
				<span
					key={i}
					style={{ color: dark ? "#1D9BD1" : "#1264A3", fontWeight: 600 }}
				>
					{p}
				</span>
			);
		}
		if (/^`[^`]+`$/.test(p)) {
			return (
				<code
					key={i}
					style={{
						background: dark ? "#222529" : "#F8F8F8",
						border: `1px solid ${dark ? "#3a3d42" : "#E8E8E8"}`,
						borderRadius: 3,
						padding: "0 4px",
						fontFamily: 'Menlo, Consolas, "Liberation Mono", monospace',
						fontSize: 12,
						color: "#E01E5A",
					}}
				>
					{p.slice(1, -1)}
				</code>
			);
		}
		return <span key={i}>{p}</span>;
	});
}

/* =========================================================================
 * Discord — chrome and composer matched to the iOS screenshot
 * (CleanShot 2026-05-11 at 13.05.53@2x.png)
 * ========================================================================= */

function DiscordDemo({
	messages,
	title,
	subtitle,
	showComposer,
	className,
}: {
	messages: ChatMessageItem[];
	title?: string;
	subtitle?: string;
	showComposer: boolean;
	className?: string;
}) {
	const bg = "#1E1F22";
	const fg = "#DBDEE1";
	const muted = "#949BA4";
	const iconBg = "#2B2D31";

	const groups = groupByAuthor(messages);

	return (
		<div
			className={cn("flex h-full flex-col", className)}
			style={{ background: bg, color: fg, fontFamily: DISCORD_STACK }}
		>
			{/* Channel header */}
			<div
				className="flex shrink-0 items-center gap-2 px-3"
				style={{
					background: bg,
					height: 48,
					borderBottom: "1px solid rgba(0,0,0,0.4)",
					zIndex: 2,
				}}
			>
				<button
					type="button"
					aria-label="Back"
					className="flex cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-white/[0.06]"
					style={{ width: 28, height: 28, color: fg }}
				>
					<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden fill="none">
						<path
							d="M14 6l-6 6 6 6"
							stroke="currentColor"
							strokeWidth="2"
							strokeLinecap="round"
							strokeLinejoin="round"
						/>
					</svg>
				</button>
				<span style={{ fontSize: 18, color: muted, marginLeft: 2 }}>#</span>
				<span
					style={{
						fontSize: 17,
						fontWeight: 700,
						color: "#FFFFFF",
						letterSpacing: "-0.01em",
					}}
				>
					{title ?? "general"}
				</span>
				<svg
					width="14"
					height="14"
					viewBox="0 0 24 24"
					aria-hidden
					fill="none"
					style={{ marginLeft: 2, color: muted }}
				>
					<path
						d="M9 6l6 6-6 6"
						stroke="currentColor"
						strokeWidth="2"
						strokeLinecap="round"
						strokeLinejoin="round"
					/>
				</svg>
				<div className="flex flex-1" />
				<button
					type="button"
					aria-label="Search"
					className="flex cursor-pointer items-center justify-center rounded-full transition-[filter] duration-150 hover:brightness-125 active:brightness-90"
					style={{ width: 32, height: 32, background: iconBg, color: muted }}
				>
					<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden fill="none">
						<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
						<path
							d="m20 20-4-4"
							stroke="currentColor"
							strokeWidth="2"
							strokeLinecap="round"
						/>
					</svg>
				</button>
			</div>

			<div
				className="flex flex-1 flex-col overflow-y-auto py-3"
				style={{ scrollbarWidth: "none", gap: 18 }}
			>
				{groups.map((g, gi) => (
					<div key={gi} className="flex gap-3" style={{ padding: "0 12px" }}>
						<div
							className="shrink-0 overflow-hidden rounded-full"
							style={{
								width: 40,
								height: 40,
								background: g.author?.avatar
									? undefined
									: pickColor(g.author?.name ?? ""),
							}}
						>
							{/* biome-ignore lint/performance/noImgElement: avatar */}
							<img
								src={g.author?.avatar ?? DEFAULT_AVATAR}
								alt=""
								style={{ width: 40, height: 40, objectFit: "cover" }}
							/>
						</div>
						<div className="flex min-w-0 flex-1 flex-col">
							<div className="flex items-baseline gap-2">
								<span
									style={{
										fontWeight: 600,
										fontSize: 15,
										color: g.author?.color ?? "#FFFFFF",
									}}
								>
									{g.author?.name ?? "Unknown"}
								</span>
								<span style={{ fontSize: 12, color: muted }}>
									Today at {g.items[0].time ?? ""}
								</span>
							</div>
							<div className="flex flex-col" style={{ gap: 4 }}>
								{g.items.map((m, i) => (
									<div
										key={m.id ?? `${gi}-${i}`}
										style={{
											fontSize: 15,
											lineHeight: "1.375",
											color: fg,
											wordBreak: "break-word",
											whiteSpace: "pre-wrap",
										}}
									>
										{m.typing ? (
											<TypingDots color={muted} />
										) : (
											renderDiscordText(m.text ?? "")
										)}
									</div>
								))}
							</div>
							{g.items.some((m) => m.reactions?.length) && (
								<div className="mt-1 flex flex-wrap gap-1">
									{g.items.flatMap((m) =>
										(m.reactions ?? []).map((r, ri) => (
											<span
												key={`${ri}-${r.emoji}`}
												className="inline-flex items-center gap-1"
												style={{
													padding: "2px 7px",
													borderRadius: 8,
													border: "1px solid rgba(88,101,242,0.3)",
													background: "rgba(88,101,242,0.15)",
													fontSize: 13,
													fontWeight: 600,
													color: "#A8B6FF",
												}}
											>
												<span>{r.emoji}</span>
												{r.count}
											</span>
										)),
									)}
								</div>
							)}
						</div>
					</div>
				))}
			</div>

			{/* Composer — 4 circular buttons + flat pill input + mic, per screenshot */}
			{showComposer && (
				<div
					className="flex shrink-0 items-center gap-2 px-3 pt-2 pb-2"
					style={{ background: bg }}
				>
					<DiscordCircleButton bg={iconBg} fg={fg} label="Add">
						<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden fill="none">
							<path
								d="M12 5v14M5 12h14"
								stroke="currentColor"
								strokeWidth="2.4"
								strokeLinecap="round"
							/>
						</svg>
					</DiscordCircleButton>
					<DiscordCircleButton bg={iconBg} fg={fg} label="Stickers">
						<DiscordStickerIcon size={20} />
					</DiscordCircleButton>
					<DiscordCircleButton bg={iconBg} fg="#5865F2" label="Gift">
						<DiscordGiftIcon size={20} />
					</DiscordCircleButton>
					<div
						className="flex flex-1 items-center"
						style={{
							background: iconBg,
							borderRadius: 20,
							padding: "0 12px",
							height: 36,
						}}
					>
						<input
							type="text"
							placeholder="Message"
							className="chat-demo-input min-w-0 flex-1 border-0 bg-transparent p-0 outline-none placeholder:text-[#949BA4]"
							style={{
								fontSize: 15,
								color: fg,
								fontFamily: "inherit",
							}}
						/>
					</div>
					<DiscordCircleButton bg={iconBg} fg={fg} label="Voice">
						<MicIcon size={18} />
					</DiscordCircleButton>
				</div>
			)}
		</div>
	);
}

function DiscordCircleButton({
	bg,
	fg,
	label,
	children,
}: {
	bg: string;
	fg: string;
	label: string;
	children: React.ReactNode;
}) {
	return (
		<button
			type="button"
			aria-label={label}
			className="flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-[filter,opacity] duration-150 hover:brightness-125 active:brightness-90"
			style={{ width: 36, height: 36, background: bg, color: fg }}
		>
			{children}
		</button>
	);
}

function renderDiscordText(text: string) {
	const parts = text.split(/(@\w+|#\w+|`[^`]+`|:\w+:)/g);
	return parts.map((p, i) => {
		if (/^@\w+/.test(p)) {
			return (
				<span
					key={i}
					style={{
						background: "rgba(88,101,242,0.3)",
						color: "#C9CDFB",
						padding: "0 2px",
						borderRadius: 3,
						fontWeight: 500,
					}}
				>
					{p}
				</span>
			);
		}
		if (/^#\w+/.test(p)) {
			return (
				<span key={i} style={{ color: "#00A8FC", fontWeight: 500 }}>
					{p}
				</span>
			);
		}
		if (/^`[^`]+`$/.test(p)) {
			return (
				<code
					key={i}
					style={{
						background: "#2B2D31",
						borderRadius: 3,
						padding: "0 4px",
						fontFamily: '"Menlo", Consolas, monospace',
						fontSize: 13.6,
					}}
				>
					{p.slice(1, -1)}
				</code>
			);
		}
		return <span key={i}>{p}</span>;
	});
}

/* =========================================================================
 * Shared helpers
 * ========================================================================= */

function TypingDots({ color }: { color: string }) {
	return (
		<span
			aria-label="typing"
			style={{
				display: "inline-flex",
				alignItems: "center",
				gap: 4,
				padding: "2px 0",
			}}
		>
			{[0, 1, 2].map((i) => (
				<span
					key={i}
					style={{
						width: 6,
						height: 6,
						borderRadius: 9999,
						background: color,
						opacity: 0.6,
						animation: `chat-demo-typing 1.2s ${i * 0.15}s infinite ease-in-out`,
						display: "inline-block",
					}}
				/>
			))}
			<style>{`
				@keyframes chat-demo-typing {
					0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
					30% { transform: translateY(-3px); opacity: 0.9; }
				}
			`}</style>
		</span>
	);
}

/* ----- Shared composer icons (user-provided paths) ----- */

function MicIcon({ size = 22 }: { size?: number }) {
	return (
		<svg
			width={size}
			height={size}
			viewBox="0 0 24 24"
			aria-hidden
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
		>
			<path d="M19 10V12C19 15.866 15.866 19 12 19M5 10V12C5 15.866 8.13401 19 12 19M12 19V22M8 22H16M12 15C10.3431 15 9 13.6569 9 12V5C9 3.34315 10.3431 2 12 2C13.6569 2 15 3.34315 15 5V12C15 13.6569 13.6569 15 12 15Z" />
		</svg>
	);
}

function AttachmentIcon({ size = 22 }: { size?: number }) {
	return (
		<svg
			width={size}
			height={size}
			viewBox="0 0 24 24"
			aria-hidden
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
		>
			<path d="M21.1525 10.8995L12.1369 19.9151C10.0866 21.9653 6.7625 21.9653 4.71225 19.9151C2.662 17.8648 2.662 14.5407 4.71225 12.4904L13.7279 3.47483C15.0947 2.108 17.3108 2.108 18.6776 3.47483C20.0444 4.84167 20.0444 7.05775 18.6776 8.42458L10.0156 17.0866C9.33213 17.7701 8.22409 17.7701 7.54068 17.0866C6.85726 16.4032 6.85726 15.2952 7.54068 14.6118L15.1421 7.01037" />
		</svg>
	);
}

function CameraIcon({ size = 22 }: { size?: number }) {
	return (
		<svg
			width={size}
			height={size}
			viewBox="0 0 24 24"
			aria-hidden
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
		>
			<path d="M2 8.37722C2 8.0269 2 7.85174 2.01462 7.70421C2.1556 6.28127 3.28127 5.1556 4.70421 5.01462C4.85174 5 5.03636 5 5.40558 5C5.54785 5 5.61899 5 5.67939 4.99634C6.45061 4.94963 7.12595 4.46288 7.41414 3.746C7.43671 3.68986 7.45781 3.62657 7.5 3.5C7.54219 3.37343 7.56329 3.31014 7.58586 3.254C7.87405 2.53712 8.54939 2.05037 9.32061 2.00366C9.38101 2 9.44772 2 9.58114 2H14.4189C14.5523 2 14.619 2 14.6794 2.00366C15.4506 2.05037 16.126 2.53712 16.4141 3.254C16.4367 3.31014 16.4578 3.37343 16.5 3.5C16.5422 3.62657 16.5633 3.68986 16.5859 3.746C16.874 4.46288 17.5494 4.94963 18.3206 4.99634C18.381 5 18.4521 5 18.5944 5C18.9636 5 19.1483 5 19.2958 5.01462C20.7187 5.1556 21.8444 6.28127 21.9854 7.70421C22 7.85174 22 8.0269 22 8.37722V16.2C22 17.8802 22 18.7202 21.673 19.362C21.3854 19.9265 20.9265 20.3854 20.362 20.673C19.7202 21 18.8802 21 17.2 21H6.8C5.11984 21 4.27976 21 3.63803 20.673C3.07354 20.3854 2.6146 19.9265 2.32698 19.362C2 18.7202 2 17.8802 2 16.2V8.37722Z" />
			<path d="M12 16.5C14.2091 16.5 16 14.7091 16 12.5C16 10.2909 14.2091 8.5 12 8.5C9.79086 8.5 8 10.2909 8 12.5C8 14.7091 9.79086 16.5 12 16.5Z" />
		</svg>
	);
}

function DiscordStickerIcon({ size = 20 }: { size?: number }) {
	return (
		<svg
			xmlns="http://www.w3.org/2000/svg"
			viewBox="0 0 96 96"
			width={size}
			height={size}
			aria-hidden
		>
			<g transform="matrix(2.7,0,0,2.7,-79.6,-81.35)">
				<g transform="translate(32.022 32.647)">
					<g transform="translate(7.18 7.181)">
						<path
							fill="currentColor"
							d="M-6.555 1.641c-.375 1.401.455 2.84 1.856 3.216l6.34 1.698c1.4.376 2.841-.456 3.216-1.856L6.555-1.641C6.93-3.042 6.099-4.481 4.699-4.857L-1.641-6.556c-1.4-.375-2.841.457-3.216 1.857z"
						/>
					</g>
				</g>
				<g transform="translate(47.443 32.419)">
					<g transform="translate(7.802 7.022)">
						<path
							fill="currentColor"
							d="M-6.478 2.404C-7.552 4.372-6.127 6.772-3.885 6.772h7.771c2.242 0 3.666-2.4 2.593-4.368L2.593-4.719c-1.119-2.053-4.066-2.053-5.186 0z"
						/>
					</g>
				</g>
				<g transform="translate(32.12 49.045)">
					<g transform="translate(7.082 7.047)">
						<path
							fill="currentColor"
							d="M-.941-6.268c.52-.529 1.361-.529 1.882 0l1.116 1.135c.213.216.491.353.789.388l1.566.183c.729.085 1.254.756 1.173 1.501l-.174 1.598a1.4 1.4 0 0 0 .195.872l.836 1.363c.39.635.203 1.472-.419 1.872l-1.333.858a1.4 1.4 0 0 0-.547.699l-.523 1.517c-.243.706-1.001 1.079-1.695.833l-1.488-.529a1.32 1.32 0 0 0-.876 0l-1.489.529c-.693.246-1.451-.127-1.695-.833l-.523-1.517a1.4 1.4 0 0 0-.546-.699L-6.024 2.644c-.621-.4-.808-1.237-.419-1.872l.837-1.363a1.4 1.4 0 0 0 .194-.872l-.173-1.598c-.081-.745.443-1.416 1.173-1.501l1.565-.183c.299-.035.577-.172.79-.388z"
						/>
					</g>
				</g>
				<g transform="matrix(-1,0,0,-1,62.694,63.32)">
					<g transform="translate(7.247 7.247)">
						<path
							fill="currentColor"
							d="M1.513-5.592C.993-6.997-.994-6.997-1.514-5.592l-.677 1.831c-.269.727-.843 1.301-1.571 1.57l-1.83.678c-1.405.52-1.405 2.506 0 3.026l1.83.678c.728.269 1.302.843 1.571 1.57l.677 1.831c.52 1.405 2.507 1.405 3.027 0l.677-1.831c.27-.727.843-1.301 1.571-1.57l1.832-.678c1.405-.52 1.405-2.506 0-3.026l-1.832-.678c-.728-.269-1.301-.843-1.571-1.57z"
						/>
					</g>
				</g>
			</g>
		</svg>
	);
}

function DiscordGiftIcon({ size = 20 }: { size?: number }) {
	// Verbatim Lottie SVG provided by the user — transforms preserved as-is.
	return (
		<svg
			xmlns="http://www.w3.org/2000/svg"
			viewBox="0 0 24 24"
			width={size}
			height={size}
			aria-hidden
		>
			<g transform="matrix(0.04,0,0,0.04,0,0)">
				<g transform="matrix(25,0,0,25,300,300)">
					<path
						fill="currentColor"
						d="M-7,10 C-8.105,10 -9,9.105 -9,8 V2.5 C-9,2.224 -8.776,2 -8.5,2 H-1.5 C-1.224,2 -1,2.224 -1,2.5 V9.5 C-1,9.776 -1.224,10 -1.5,10 Z M1,9.5 C1,9.776 1.224,10 1.5,10 H7 C8.105,10 9,9.105 9,8 V2.5 C9,2.224 8.776,2 8.5,2 H1.5 C1.224,2 1,2.224 1,2.5 Z"
					/>
				</g>
				<g transform="matrix(25,0,0,25,300,300)">
					<path
						fill="currentColor"
						d="M-10,-2 C-10,-3.105 -9.105,-4 -8,-4 H8 C9.105,-4 10,-3.105 10,-2 V-0.5 C10,-0.224 9.776,0 9.5,0 H-9.5 C-9.776,0 -10,-0.224 -10,-0.5 Z"
					/>
				</g>
				<g transform="matrix(25,0,0,25,300,300)">
					<path
						fill="none"
						stroke="currentColor"
						strokeWidth="2"
						strokeLinejoin="round"
						d="M7,-6 C7,-7.657 5.657,-9 4,-9 H3.911 C2.494,-9 1.259,-8.036 0.915,-6.661 L0,-3 H4 C5.657,-3 7,-4.343 7,-6 Z"
					/>
				</g>
				<g transform="matrix(25,0,0,25,300,300)">
					<path
						fill="none"
						stroke="currentColor"
						strokeWidth="2"
						strokeLinejoin="round"
						d="M-7,-6 C-7,-7.657 -5.657,-9 -4,-9 H-3.911 C-2.494,-9 -1.259,-8.036 -0.915,-6.661 L0,-3 H-4 C-5.657,-3 -7,-4.343 -7,-6 Z"
					/>
				</g>
			</g>
		</svg>
	);
}

function EmojiIcon({ size = 22 }: { size?: number }) {
	return (
		<svg
			width={size}
			height={size}
			viewBox="0 0 24 24"
			aria-hidden
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
		>
			<path d="M8 14C8 14 9.5 16 12 16C14.5 16 16 14 16 14M15 9H15.01M9 9H9.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM15.5 9C15.5 9.27614 15.2761 9.5 15 9.5C14.7239 9.5 14.5 9.27614 14.5 9C14.5 8.72386 14.7239 8.5 15 8.5C15.2761 8.5 15.5 8.72386 15.5 9ZM9.5 9C9.5 9.27614 9.27614 9.5 9 9.5C8.72386 9.5 8.5 9.27614 8.5 9C8.5 8.72386 8.72386 8.5 9 8.5C9.27614 8.5 9.5 8.72386 9.5 9Z" />
		</svg>
	);
}

function pickColor(seed: string) {
	const palette = [
		"#5865F2",
		"#EB459E",
		"#F23F42",
		"#23A55A",
		"#F0B232",
		"#FF7C5C",
		"#9B7CFF",
		"#1ABC9C",
		"#3498DB",
		"#E74C3C",
	];
	let hash = 0;
	for (let i = 0; i < seed.length; i++)
		hash = (hash << 5) - hash + seed.charCodeAt(i);
	return palette[Math.abs(hash) % palette.length];
}

type AuthorGroup = {
	author?: { name: string; avatar?: string; color?: string };
	items: ChatMessageItem[];
};

function groupByAuthor(messages: ChatMessageItem[]): AuthorGroup[] {
	const out: AuthorGroup[] = [];
	for (const m of messages) {
		const last = out[out.length - 1];
		if (last && last.author?.name === m.author) {
			last.items.push(m);
		} else {
			out.push({
				author: {
					name: m.author ?? "Unknown",
					avatar: m.avatar,
					color: m.authorColor,
				},
				items: [m],
			});
		}
	}
	return out;
}

More from gaia

Browse all components in this library →