Chat Support Widget
Original · freefloating support chat widget
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"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'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'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
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

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

