Web InnoventixFreeCode

Chat Support Widget

Original · free

floating support chat widget

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

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Sender = "agent" | "you";

type Message = {
  id: number;
  sender: Sender;
  text: string;
  time: string;
};

type QuickReply = {
  id: string;
  label: string;
  question: string;
  match: RegExp;
  answer: string;
};

const QUICK_REPLIES: QuickReply[] = [
  {
    id: "billing",
    label: "Billing question",
    question: "I was charged twice this month — can you check?",
    match: /bill|charge|charged|invoice|refund|payment|card|price|plan/,
    answer:
      "I can see two authorisations on 3 Jul: one settled charge of $49 and one pending hold that expires in 5 business days. The hold never becomes a charge. If your bank still shows it on 12 Jul, reply here and I'll push a manual release.",
  },
  {
    id: "seat",
    label: "Add a teammate",
    question: "How do I add someone to our workspace?",
    match: /seat|teammate|colleague|invite|member|workspace|permission|role|\badd\b/,
    answer:
      "Settings → Members → Invite. Paste their work email and pick a role. Editors can ship changes, Viewers can only read. Seats are prorated to the day, so mid-cycle invites cost less than a full month.",
  },
  {
    id: "export",
    label: "Export my data",
    question: "Can I export everything before we migrate?",
    match: /export|download|migrat|backup|archive|\bdata\b/,
    answer:
      "Yes — Settings → Data → Request export. You get a signed ZIP with JSON records plus original file uploads. Exports under 2 GB land in about 10 minutes; larger ones email you a link when ready.",
  },
  {
    id: "human",
    label: "Talk to a human",
    question: "This isn't working. Can I speak to a person?",
    match: /human|person|speak|someone|agent|call|talk|support/,
    answer:
      "Of course. I've flagged this thread for the support team — Priya picks up threads weekdays 09:00–18:00 IST, usually within 20 minutes. Everything above stays in the transcript, so you won't repeat yourself.",
  },
];

const SEED: Message[] = [
  {
    id: 1,
    sender: "agent",
    text: "Hi, I'm Ada from support. Ask me anything about billing, seats, or exports — I'll pull the answer from your account.",
    time: "10:41",
  },
];

function clockNow(): string {
  const d = new Date();
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

export default function ChatSupportWidget() {
  const reduce = useReducedMotion();
  const panelId = useId();
  const logId = useId();

  const [open, setOpen] = useState<boolean>(true);
  const [messages, setMessages] = useState<Message[]>(SEED);
  const [draft, setDraft] = useState<string>("");
  const [typing, setTyping] = useState<boolean>(false);
  const [unread, setUnread] = useState<number>(0);

  const nextId = useRef<number>(2);
  const timers = useRef<number[]>([]);
  const scrollRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const launcherRef = useRef<HTMLButtonElement | null>(null);
  const rootRef = useRef<HTMLDivElement | null>(null);
  const openRef = useRef<boolean>(true);
  const typingRef = useRef<boolean>(false);

  useEffect(() => {
    openRef.current = open;
  }, [open]);

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

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" });
  }, [messages, typing, reduce]);

  const push = useCallback((sender: Sender, text: string) => {
    const entry: Message = {
      id: nextId.current++,
      sender,
      text,
      time: clockNow(),
    };
    setMessages((prev) => [...prev, entry]);
  }, []);

  const reply = useCallback(
    (text: string) => {
      typingRef.current = true;
      setTyping(true);
      const t = window.setTimeout(() => {
        typingRef.current = false;
        setTyping(false);
        push("agent", text);
        if (!openRef.current) setUnread((n) => n + 1);
        timers.current = timers.current.filter((id) => id !== t);
      }, 1100);
      timers.current.push(t);
    },
    [push],
  );

  const answerFor = useCallback((input: string): string => {
    const q = input.toLowerCase();
    const hit = QUICK_REPLIES.find((r) => r.match.test(q));
    if (hit) return hit.answer;
    return "Got it — I've logged that against your workspace. Support reads every thread; if it needs a human, Priya replies weekdays 09:00–18:00 IST. Meanwhile, the shortcuts below cover the three things people ask most.";
  }, []);

  const send = useCallback(
    (text: string, preset?: string) => {
      const value = text.trim();
      if (!value || typingRef.current) return;
      push("you", value);
      setDraft("");
      reply(preset ?? answerFor(value));
    },
    [answerFor, push, reply],
  );

  const openPanel = useCallback(() => {
    setOpen(true);
    setUnread(0);
  }, []);

  const closePanel = useCallback(() => {
    setOpen(false);
    launcherRef.current?.focus();
  }, []);

  useEffect(() => {
    if (!open) return;
    const t = window.setTimeout(() => inputRef.current?.focus(), reduce ? 0 : 220);
    return () => window.clearTimeout(t);
  }, [open, reduce]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      const root = rootRef.current;
      if (!root || !root.contains(document.activeElement)) return;
      e.stopPropagation();
      closePanel();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, closePanel]);

  const transition = useMemo(
    () =>
      reduce
        ? { duration: 0 }
        : { type: "spring" as const, stiffness: 420, damping: 34, mass: 0.8 },
    [reduce],
  );

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes cswDotPulse {
          0%, 60%, 100% { transform: translateY(0); opacity: .45; }
          30% { transform: translateY(-3px); opacity: 1; }
        }
        @keyframes cswHalo {
          0%, 100% { transform: scale(1); opacity: .5; }
          50% { transform: scale(1.35); opacity: 0; }
        }
        .csw-dot { animation: cswDotPulse 1.05s ease-in-out infinite; }
        .csw-dot:nth-child(2) { animation-delay: .14s; }
        .csw-dot:nth-child(3) { animation-delay: .28s; }
        .csw-halo { animation: cswHalo 2.6s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .csw-dot, .csw-halo { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background:radial-gradient(60rem_40rem_at_85%_-10%,rgba(99,102,241,0.10),transparent_60%)] dark:[background:radial-gradient(60rem_40rem_at_85%_-10%,rgba(129,140,248,0.14),transparent_60%)]"
      />

      <div className="relative mx-auto grid max-w-6xl items-center gap-14 lg:grid-cols-[minmax(0,1fr)_26rem]">
        <div className="max-w-xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span className="csw-halo absolute inset-0 rounded-full bg-emerald-500" />
              <span className="relative h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Median first reply: 42 seconds
          </span>

          <h2 className="mt-5 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-slate-50">
            Support that answers before the frustration starts.
          </h2>

          <p className="mt-4 text-lg leading-relaxed text-slate-600 dark:text-slate-400">
            The widget reads the account it&apos;s embedded in, so billing holds,
            seat maths, and export sizes come back as real numbers — not a link to
            a help centre article. When it can&apos;t answer, it hands the thread
            to a person with the whole transcript attached.
          </p>

          <dl className="mt-10 grid grid-cols-3 gap-6 border-t border-slate-200 pt-6 dark:border-slate-800">
            {[
              { k: "Resolved in-thread", v: "71%" },
              { k: "Threads escalated", v: "1 in 4" },
              { k: "Transcript kept", v: "90 days" },
            ].map((s) => (
              <div key={s.k}>
                <dt className="text-xs text-slate-500 dark:text-slate-500">{s.k}</dt>
                <dd className="mt-1 text-xl font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                  {s.v}
                </dd>
              </div>
            ))}
          </dl>
        </div>

        <div className="relative flex min-h-[34rem] justify-center lg:justify-end">
          <div
            ref={rootRef}
            className="relative flex w-full max-w-sm flex-col items-end justify-end gap-4"
          >
            <AnimatePresence initial={false}>
              {open ? (
                <motion.div
                  key="panel"
                  id={panelId}
                  role="dialog"
                  aria-modal="false"
                  aria-label="Support chat with Ada"
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.97 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.98 }}
                  transition={transition}
                  style={{ transformOrigin: "bottom right" }}
                  className="flex h-[30rem] w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-slate-900/10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/50"
                >
                  <header className="flex items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
                    <span className="relative grid h-9 w-9 shrink-0 place-items-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
                      A
                      <span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900" />
                    </span>
                    <div className="min-w-0 flex-1">
                      <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                        Ada · Support
                      </p>
                      <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                        {typing ? "Typing…" : "Online · replies in seconds"}
                      </p>
                    </div>
                    <button
                      type="button"
                      onClick={closePanel}
                      aria-label="Close support chat"
                      className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
                    >
                      <svg
                        viewBox="0 0 20 20"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        strokeLinecap="round"
                        aria-hidden="true"
                        className="h-4 w-4"
                      >
                        <path d="M5 5l10 10M15 5L5 15" />
                      </svg>
                    </button>
                  </header>

                  <div
                    ref={scrollRef}
                    id={logId}
                    role="log"
                    aria-live="polite"
                    aria-label="Conversation"
                    tabIndex={0}
                    className="flex-1 space-y-3 overflow-y-auto px-4 py-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
                  >
                    {messages.map((m) => (
                      <motion.div
                        key={m.id}
                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={reduce ? { duration: 0 } : { duration: 0.24, ease: "easeOut" }}
                        className={`flex ${m.sender === "you" ? "justify-end" : "justify-start"}`}
                      >
                        <div
                          className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed ${
                            m.sender === "you"
                              ? "rounded-br-md bg-indigo-600 text-white"
                              : "rounded-bl-md bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200"
                          }`}
                        >
                          <p>{m.text}</p>
                          <p
                            className={`mt-1.5 text-[11px] tabular-nums ${
                              m.sender === "you"
                                ? "text-indigo-200"
                                : "text-slate-500 dark:text-slate-500"
                            }`}
                          >
                            <span className="sr-only">
                              {m.sender === "you" ? "You, " : "Ada, "}
                            </span>
                            {m.time}
                          </p>
                        </div>
                      </motion.div>
                    ))}

                    {typing ? (
                      <div className="flex justify-start">
                        <div className="flex items-center gap-1 rounded-2xl rounded-bl-md bg-slate-100 px-3.5 py-3 dark:bg-slate-800">
                          <span className="sr-only">Ada is typing</span>
                          {[0, 1, 2].map((i) => (
                            <span
                              key={i}
                              aria-hidden="true"
                              className="csw-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400"
                            />
                          ))}
                        </div>
                      </div>
                    ) : null}
                  </div>

                  <div className="border-t border-slate-200 px-4 pt-3 dark:border-slate-800">
                    <p className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
                      Common questions
                    </p>
                    <div className="mt-2 flex flex-wrap gap-1.5">
                      {QUICK_REPLIES.map((r) => (
                        <button
                          key={r.id}
                          type="button"
                          onClick={() => send(r.question, r.answer)}
                          aria-disabled={typing}
                          className={`rounded-full border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors 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:text-slate-300 dark:focus-visible:ring-offset-slate-900 ${
                            typing
                              ? "cursor-not-allowed opacity-40"
                              : "hover:border-indigo-400 hover:bg-indigo-50 hover:text-indigo-700 dark:hover:border-indigo-500 dark:hover:bg-indigo-950 dark:hover:text-indigo-300"
                          }`}
                        >
                          {r.label}
                        </button>
                      ))}
                    </div>
                  </div>

                  <form
                    onSubmit={(e) => {
                      e.preventDefault();
                      send(draft);
                    }}
                    className="flex items-center gap-2 px-4 py-3"
                  >
                    <label htmlFor={`${panelId}-input`} className="sr-only">
                      Write a message to support
                    </label>
                    <input
                      id={`${panelId}-input`}
                      ref={inputRef}
                      type="text"
                      value={draft}
                      onChange={(e) => setDraft(e.target.value)}
                      autoComplete="off"
                      placeholder="Ask about billing, seats, exports…"
                      aria-controls={logId}
                      className="min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
                    />
                    <button
                      type="submit"
                      aria-disabled={!draft.trim() || typing}
                      className={`grid h-9 w-9 shrink-0 place-items-center rounded-xl text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                        !draft.trim() || typing
                          ? "cursor-not-allowed bg-slate-300 dark:bg-slate-700"
                          : "bg-indigo-600 hover:bg-indigo-500"
                      }`}
                    >
                      <span className="sr-only">Send message</span>
                      <svg
                        viewBox="0 0 20 20"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                        className="h-4 w-4"
                      >
                        <path d="M3.5 10h13M11 4.5L16.5 10 11 15.5" />
                      </svg>
                    </button>
                  </form>
                </motion.div>
              ) : null}
            </AnimatePresence>

            <button
              ref={launcherRef}
              type="button"
              onClick={() => (open ? closePanel() : openPanel())}
              aria-expanded={open}
              aria-controls={open ? panelId : undefined}
              aria-label={
                open
                  ? "Close support chat"
                  : unread > 0
                    ? `Open support chat, ${unread} unread ${unread === 1 ? "message" : "messages"}`
                    : "Open support chat"
              }
              className="relative grid h-14 w-14 shrink-0 place-items-center rounded-full bg-slate-900 text-white shadow-xl shadow-slate-900/25 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-95 dark:bg-white dark:text-slate-900 dark:shadow-black/50 dark:focus-visible:ring-offset-slate-950"
            >
              {open ? (
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  aria-hidden="true"
                  className="h-5 w-5"
                >
                  <path d="M6 6l12 12M18 6L6 18" />
                </svg>
              ) : (
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                  className="h-6 w-6"
                >
                  <path d="M21 11.5a8.4 8.4 0 01-9 8.4 9 9 0 01-3.9-.9L3 20.5l1.6-4.6A8.4 8.4 0 013.1 11 8.4 8.4 0 0112 3a8.4 8.4 0 019 8.5z" />
                  <path d="M8.5 11h.01M12 11h.01M15.5 11h.01" />
                </svg>
              )}

              {!open && unread > 0 ? (
                <span className="absolute -right-0.5 -top-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-rose-500 px-1 text-[11px] font-semibold tabular-nums text-white ring-2 ring-slate-50 dark:ring-slate-950">
                  {unread}
                </span>
              ) : null}
            </button>
          </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 →