Web InnoventixFreeCode

Chat Conversation

Original Β· free

full conversation thread with avatars

byWeb InnoventixReact + Tailwind
chatconversation
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-conversation.json
chat-conversation.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type FormEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Author = {
  id: string;
  name: string;
  role: string;
  initials: string;
  avatar: string;
  online: boolean;
};

type Message = {
  id: string;
  authorId: string;
  text: string;
  time: string;
  reactions: Record<string, number>;
};

const AUTHORS: Record<string, Author> = {
  maya: {
    id: "maya",
    name: "Maya Ellison",
    role: "Product Design Lead",
    initials: "ME",
    avatar: "from-violet-500 to-indigo-600",
    online: true,
  },
  theo: {
    id: "theo",
    name: "Theo Nakamura",
    role: "Staff Engineer",
    initials: "TN",
    avatar: "from-sky-500 to-emerald-600",
    online: true,
  },
  you: {
    id: "you",
    name: "You",
    role: "Engineering Manager",
    initials: "YO",
    avatar: "from-indigo-500 to-violet-600",
    online: true,
  },
};

const SEED: Message[] = [
  {
    id: "m1",
    authorId: "maya",
    text: "Pushed the checkout revision to Figma β€” page 3 has the one-step variant we argued about on Tuesday.",
    time: "9:02",
    reactions: {},
  },
  {
    id: "m2",
    authorId: "maya",
    text: "The address form is collapsed by default now. That's roughly 400px of scroll gone on a mid-size phone.",
    time: "9:02",
    reactions: { "πŸ‘€": 2 },
  },
  {
    id: "m3",
    authorId: "theo",
    text: "Looked at it. One-step is fine on our side β€” address autocomplete resolves before the user stops typing, so collapsing it doesn't cost us a round trip.",
    time: "9:14",
    reactions: { "πŸ‘": 3 },
  },
  {
    id: "m4",
    authorId: "theo",
    text: "One problem: we can't validate the postcode until a country is picked. If country sits inside the collapsed block, error states arrive out of nowhere.",
    time: "9:15",
    reactions: {},
  },
  {
    id: "m5",
    authorId: "you",
    text: "Good catch. Maya β€” can country move above the collapse?",
    time: "9:21",
    reactions: {},
  },
  {
    id: "m6",
    authorId: "maya",
    text: "Yes. I'll lift country and postcode into the always-visible row. Street and unit stay inside the collapse, which is the part nobody rereads anyway.",
    time: "9:26",
    reactions: { "πŸŽ‰": 1, "πŸ‘": 2 },
  },
  {
    id: "m7",
    authorId: "theo",
    text: "Works for me. I'll fire validation on blur instead of keystroke so it stops shouting at people mid-typing.",
    time: "9:30",
    reactions: {},
  },
];

const REPLIES: { authorId: string; text: string }[] = [
  {
    authorId: "maya",
    text: "Noted β€” updated frames will be in this thread before standup tomorrow.",
  },
  {
    authorId: "theo",
    text: "Makes sense. I'll put it on the checkout branch and ping you once staging rebuilds.",
  },
  {
    authorId: "maya",
    text: "Agreed. Keep the collapse, but country and postcode live outside it.",
  },
];

const EMOJI = ["πŸ‘", "πŸŽ‰", "πŸ‘€", "βœ…"];

function ReactionPicker({
  reduced,
  picked,
  align,
  onPick,
  onClose,
}: {
  reduced: boolean;
  picked: string[];
  align: "left" | "right";
  onPick: (emoji: string) => void;
  onClose: (restoreFocus: boolean) => void;
}) {
  const [index, setIndex] = useState(0);
  const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);
  const rootRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    itemsRef.current[0]?.focus();
  }, []);

  useEffect(() => {
    const onPointerDown = (e: PointerEvent) => {
      const root = rootRef.current;
      if (root && e.target instanceof Node && !root.contains(e.target)) {
        onClose(false);
      }
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [onClose]);

  const move = (next: number) => {
    const bounded = (next + EMOJI.length) % EMOJI.length;
    setIndex(bounded);
    itemsRef.current[bounded]?.focus();
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      move(index + 1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      move(index - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      move(0);
    } else if (e.key === "End") {
      e.preventDefault();
      move(EMOJI.length - 1);
    } else if (e.key === "Escape" || e.key === "Tab") {
      e.preventDefault();
      onClose(true);
    }
  };

  return (
    <motion.div
      ref={rootRef}
      role="menu"
      aria-label="Pick a reaction"
      aria-orientation="horizontal"
      onKeyDown={onKeyDown}
      initial={reduced ? false : { opacity: 0, y: 6, scale: 0.94 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      transition={{ duration: reduced ? 0 : 0.15, ease: "easeOut" }}
      className={`absolute bottom-9 z-10 flex gap-0.5 rounded-full border border-slate-200 bg-white p-1 shadow-lg dark:border-slate-700 dark:bg-slate-800 ${
        align === "right" ? "right-0" : "left-0"
      }`}
    >
      {EMOJI.map((emo, i) => (
        <button
          key={emo}
          ref={(node: HTMLButtonElement | null) => {
            itemsRef.current[i] = node;
          }}
          type="button"
          role="menuitemcheckbox"
          aria-checked={picked.includes(emo)}
          aria-label={`React with ${emo}`}
          tabIndex={i === index ? 0 : -1}
          onFocus={() => setIndex(i)}
          onClick={() => onPick(emo)}
          className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-800 ${
            picked.includes(emo)
              ? "bg-indigo-50 dark:bg-indigo-500/20"
              : "hover:bg-slate-100 dark:hover:bg-slate-700"
          }`}
        >
          <span aria-hidden="true">{emo}</span>
        </button>
      ))}
    </motion.div>
  );
}

function Avatar({ author, size }: { author: Author; size: "sm" | "md" }) {
  const box = size === "sm" ? "h-8 w-8 text-[11px]" : "h-9 w-9 text-xs";
  return (
    <span className="relative inline-flex shrink-0">
      <span
        aria-hidden="true"
        className={`inline-flex ${box} items-center justify-center rounded-full bg-gradient-to-br ${author.avatar} font-semibold tracking-wide text-white ring-2 ring-white dark:ring-slate-900`}
      >
        {author.initials}
      </span>
      {author.online ? (
        <span
          aria-hidden="true"
          className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900"
        />
      ) : null}
    </span>
  );
}

export default function ChatConversation() {
  const reduced = useReducedMotion();
  const uid = useId();
  const [messages, setMessages] = useState<Message[]>(SEED);
  const [draft, setDraft] = useState("");
  const [typingId, setTypingId] = useState<string | null>(null);
  const [pickerFor, setPickerFor] = useState<string | null>(null);
  const [mine, setMine] = useState<Record<string, string[]>>({});
  const [atBottom, setAtBottom] = useState(true);

  const scrollRef = useRef<HTMLDivElement | null>(null);
  const sentinelRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
  const triggers = useRef<Record<string, HTMLButtonElement | null>>({});
  const timers = useRef<number[]>([]);
  const replyIdx = useRef(0);
  const seq = useRef(0);

  useEffect(() => {
    const list = timers.current;
    return () => {
      list.forEach((t) => window.clearTimeout(t));
    };
  }, []);

  useEffect(() => {
    const node = sentinelRef.current;
    const root = scrollRef.current;
    if (!node || !root) return;
    const io = new IntersectionObserver(
      (entries) => setAtBottom(entries[0].isIntersecting),
      { root, threshold: 0.99 },
    );
    io.observe(node);
    return () => io.disconnect();
  }, []);

  const scrollToEnd = useCallback(
    (smooth: boolean) => {
      const root = scrollRef.current;
      if (!root) return;
      root.scrollTo({
        top: root.scrollHeight,
        behavior: smooth && !reduced ? "smooth" : "auto",
      });
    },
    [reduced],
  );

  useEffect(() => {
    if (atBottom || typingId) scrollToEnd(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [messages.length, typingId]);

  useEffect(() => {
    const el = inputRef.current;
    if (!el) return;
    el.style.height = "0px";
    el.style.height = `${Math.min(el.scrollHeight, 132)}px`;
  }, [draft]);

  const closePicker = useCallback(
    (restoreFocus: boolean) => {
      if (restoreFocus && pickerFor) triggers.current[pickerFor]?.focus();
      setPickerFor(null);
    },
    [pickerFor],
  );

  const clock = () => {
    const d = new Date();
    const h = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12;
    return `${h}:${String(d.getMinutes()).padStart(2, "0")}`;
  };

  const send = (e: FormEvent) => {
    e.preventDefault();
    const text = draft.trim();
    if (!text) return;
    seq.current += 1;
    const id = `s${seq.current}`;
    setMessages((prev) => [
      ...prev,
      { id, authorId: "you", text, time: clock(), reactions: {} },
    ]);
    setDraft("");
    setAtBottom(true);

    const reply = REPLIES[replyIdx.current % REPLIES.length];
    replyIdx.current += 1;

    const t1 = window.setTimeout(() => setTypingId(reply.authorId), 500);
    const t2 = window.setTimeout(() => {
      setTypingId(null);
      seq.current += 1;
      setMessages((prev) => [
        ...prev,
        {
          id: `s${seq.current}`,
          authorId: reply.authorId,
          text: reply.text,
          time: clock(),
          reactions: {},
        },
      ]);
    }, 2300);
    timers.current.push(t1, t2);
  };

  const onDraftKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      e.currentTarget.form?.requestSubmit();
    }
  };

  const toggleReaction = (msgId: string, emoji: string) => {
    setMine((prev) => {
      const list = prev[msgId] ?? [];
      const next = list.includes(emoji)
        ? list.filter((x) => x !== emoji)
        : [...list, emoji];
      return { ...prev, [msgId]: next };
    });
  };

  const countFor = (m: Message, emoji: string) =>
    (m.reactions[emoji] ?? 0) + ((mine[m.id] ?? []).includes(emoji) ? 1 : 0);

  const keysFor = (m: Message) => {
    const set = new Set([...Object.keys(m.reactions), ...(mine[m.id] ?? [])]);
    return [...set].filter((k) => countFor(m, k) > 0);
  };

  const roster = [AUTHORS.maya, AUTHORS.theo, AUTHORS.you];

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes cchat-dot {
          0%, 60%, 100% { transform: translateY(0); opacity: .45; }
          30% { transform: translateY(-3px); opacity: 1; }
        }
        @keyframes cchat-rise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .cchat-dot { animation: cchat-dot 1.2s ease-in-out infinite; }
        .cchat-typing { animation: cchat-rise .28s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .cchat-dot, .cchat-typing { animation: none !important; }
          .cchat-scroll { scroll-behavior: auto !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <div className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            3 online
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            #checkout-redesign
          </h2>
          <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Thread on the one-step address form. Press Enter to send, Shift +
            Enter for a new line.
          </p>
        </div>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3 sm:px-5 dark:border-slate-800">
            <div className="flex items-center gap-3">
              <div className="flex -space-x-2">
                {roster.map((a) => (
                  <span key={a.id} className="inline-flex">
                    <Avatar author={a} size="sm" />
                  </span>
                ))}
              </div>
              <div className="min-w-0">
                <p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
                  Maya, Theo and you
                </p>
                <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                  Last activity 9:30 Β· Q3 checkout
                </p>
              </div>
            </div>
            <span className="hidden shrink-0 rounded-md border border-slate-200 px-2 py-1 text-[11px] font-medium text-slate-500 sm:inline dark:border-slate-700 dark:text-slate-400">
              {messages.length} messages
            </span>
          </div>

          <div className="relative">
            <div
              ref={scrollRef}
              className="cchat-scroll h-[26rem] overflow-y-auto px-3 py-4 sm:px-5"
            >
              <ol
                className="space-y-1"
                role="log"
                aria-live="polite"
                aria-relevant="additions"
                aria-label="Conversation messages"
              >
                {messages.map((m, i) => {
                  const author = AUTHORS[m.authorId];
                  const own = m.authorId === "you";
                  const prev = messages[i - 1];
                  const grouped = prev?.authorId === m.authorId;
                  const chips = keysFor(m);
                  const open = pickerFor === m.id;

                  return (
                    <motion.li
                      key={m.id}
                      initial={reduced ? false : { opacity: 0, y: 8 }}
                      animate={{ opacity: 1, y: 0 }}
                      transition={{ duration: reduced ? 0 : 0.24, ease: "easeOut" }}
                      className={`group flex gap-3 ${grouped ? "pt-0.5" : "pt-4"} ${
                        own ? "flex-row-reverse" : ""
                      }`}
                    >
                      <div className="w-9 shrink-0">
                        {!grouped ? <Avatar author={author} size="md" /> : null}
                      </div>

                      <div
                        className={`flex min-w-0 max-w-[85%] flex-col ${
                          own ? "items-end" : "items-start"
                        }`}
                      >
                        {!grouped ? (
                          <div
                            className={`mb-1 flex items-baseline gap-2 ${
                              own ? "flex-row-reverse" : ""
                            }`}
                          >
                            <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                              {author.name}
                            </span>
                            <span className="text-[11px] text-slate-400 dark:text-slate-500">
                              {m.time}
                            </span>
                          </div>
                        ) : null}

                        <div
                          className={`flex items-center gap-1 ${
                            own ? "flex-row-reverse" : ""
                          }`}
                        >
                          <div
                            className={`rounded-2xl px-3.5 py-2 text-sm leading-relaxed ${
                              own
                                ? "rounded-tr-sm bg-indigo-600 text-white dark:bg-indigo-500"
                                : "rounded-tl-sm bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200"
                            }`}
                          >
                            {m.text}
                          </div>

                          <div className="relative">
                            <button
                              ref={(node: HTMLButtonElement | null) => {
                                triggers.current[m.id] = node;
                              }}
                              type="button"
                              aria-label={`React to message from ${author.name}`}
                              aria-expanded={open}
                              aria-haspopup="menu"
                              onClick={() => setPickerFor(open ? null : m.id)}
                              className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 transition-opacity hover:bg-slate-100 hover:text-slate-600 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:opacity-0 sm:group-hover:opacity-100 dark:hover:bg-slate-800 dark:hover:text-slate-300 dark:focus-visible:ring-offset-slate-900 ${
                                open ? "opacity-100" : ""
                              }`}
                            >
                              <svg
                                viewBox="0 0 20 20"
                                aria-hidden="true"
                                className="h-4 w-4"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="1.6"
                                strokeLinecap="round"
                              >
                                <circle cx="10" cy="10" r="7.25" />
                                <path d="M7.4 12.1a3.4 3.4 0 0 0 5.2 0" />
                                <path d="M7.5 8h.01M12.5 8h.01" />
                              </svg>
                            </button>

                            {open ? (
                              <ReactionPicker
                                reduced={Boolean(reduced)}
                                picked={mine[m.id] ?? []}
                                align={own ? "right" : "left"}
                                onPick={(emo) => {
                                  toggleReaction(m.id, emo);
                                  closePicker(true);
                                }}
                                onClose={closePicker}
                              />
                            ) : null}
                          </div>
                        </div>

                        {chips.length ? (
                          <div
                            className={`mt-1 flex flex-wrap gap-1 ${
                              own ? "justify-end" : ""
                            }`}
                          >
                            {chips.map((emo) => {
                              const picked = (mine[m.id] ?? []).includes(emo);
                              return (
                                <button
                                  key={emo}
                                  type="button"
                                  aria-pressed={picked}
                                  aria-label={`${emo} reaction, ${countFor(m, emo)}${
                                    picked ? ", including yours" : ""
                                  }`}
                                  onClick={() => toggleReaction(m.id, emo)}
                                  className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                                    picked
                                      ? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-300"
                                      : "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
                                  }`}
                                >
                                  <span aria-hidden="true">{emo}</span>
                                  {countFor(m, emo)}
                                </button>
                              );
                            })}
                          </div>
                        ) : null}
                      </div>
                    </motion.li>
                  );
                })}
              </ol>

              {typingId ? (
                <div className="cchat-typing mt-4 flex items-center gap-3">
                  <Avatar author={AUTHORS[typingId]} size="md" />
                  <div className="flex items-center gap-1 rounded-2xl rounded-tl-sm bg-slate-100 px-3.5 py-3 dark:bg-slate-800">
                    {[0, 1, 2].map((d) => (
                      <span
                        key={d}
                        className="cchat-dot h-1.5 w-1.5 rounded-full bg-slate-400 dark:bg-slate-500"
                        style={{ animationDelay: `${d * 0.15}s` }}
                      />
                    ))}
                  </div>
                  <span className="sr-only">
                    {AUTHORS[typingId].name} is typing
                  </span>
                </div>
              ) : null}

              <div ref={sentinelRef} className="h-px w-full" />
            </div>

            {!atBottom ? (
              <button
                type="button"
                onClick={() => scrollToEnd(true)}
                className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 shadow-md hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                <svg
                  viewBox="0 0 20 20"
                  aria-hidden="true"
                  className="h-3.5 w-3.5"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M10 4v12M5 11l5 5 5-5" />
                </svg>
                Jump to latest
              </button>
            ) : null}
          </div>

          <form
            onSubmit={send}
            className="border-t border-slate-200 px-3 py-3 sm:px-5 dark:border-slate-800"
          >
            <div className="flex items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/20 dark:border-slate-700 dark:bg-slate-800/60">
              <Avatar author={AUTHORS.you} size="sm" />
              <label htmlFor={`${uid}-draft`} className="sr-only">
                Write a message to #checkout-redesign
              </label>
              <textarea
                id={`${uid}-draft`}
                ref={inputRef}
                rows={1}
                value={draft}
                onChange={(e) => setDraft(e.target.value)}
                onKeyDown={onDraftKey}
                placeholder="Reply to Maya and Theo…"
                className="max-h-[132px] flex-1 resize-none bg-transparent py-1 text-sm leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
              />
              <button
                type="submit"
                disabled={!draft.trim()}
                className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-600 text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:bg-slate-300 dark:disabled:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                <span className="sr-only">Send message</span>
                <svg
                  viewBox="0 0 20 20"
                  aria-hidden="true"
                  className="h-4 w-4"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M3.5 10h13M11 4.5 16.5 10 11 15.5" />
                </svg>
              </button>
            </div>
            <p className="mt-2 pl-11 text-[11px] text-slate-400 dark:text-slate-500">
              Enter to send Β· Shift + Enter for a new line
            </p>
          </form>
        </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 β†’