Web InnoventixFreeCode

Chat Bubbles

Original · free

message bubble styles both sides

byWeb InnoventixReact + Tailwind
chatbubbles
Open

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
chat-bubbles.tsx
"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

motion

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 quote

Similar components

Browse all →