EmpireUI
Get Pro
← Blog9 min read#live chat#widget#react

Live Chat Widget in React: Bubble Button, Message Window, Typing

Build a fully interactive live chat widget in React — bubble button, message window, typing indicator, and smooth animations. No third-party SDK required.

Developer coding a live chat widget on a laptop screen

Why Roll Your Own Chat Widget

Every third-party chat SDK comes with the same trade: you get a working widget in 10 minutes, and in return you ship 200 KB of JavaScript you don't control, a privacy policy you didn't write, and branding you can't fully remove on the free tier. For internal tools, support portals, or any product where UX actually matters — that's a bad deal.

Building your own in React isn't as scary as it sounds. The visual layer is just three pieces: a floating bubble button, a message window that opens above it, and a typing indicator. That's it. Real-time messaging is a separate concern you plug in via WebSocket, Supabase Realtime, or a simple polling endpoint — but none of that affects the UI architecture.

Look, if you just need Intercom-style chat on a marketing site with full analytics and CRM integration, use Intercom. But if you're building a product UI where the chat needs to match your design system exactly — and you want to browse components that share the same visual language — a custom widget is the right call.

This guide builds the entire visual shell: state management, animation, accessibility, and the typing indicator simulation. By the end you'll have something you can actually hook up to a real transport layer.

Project Structure and State Shape

Keep the widget self-contained. One parent component holds all state and renders three children: the bubble, the window, and the typing indicator inside the window. Don't scatter this across context unless you need it app-wide (you probably don't).

Here's the state shape you'll need: ``tsx type Message = { id: string; role: 'user' | 'agent'; text: string; timestamp: number; }; type ChatState = { isOpen: boolean; messages: Message[]; inputValue: string; isTyping: boolean; // agent typing indicator isMinimized: boolean; }; ` That isTyping flag drives the animated dots. isMinimized` lets users shrink the window without closing it — a small detail that makes the UX feel production-grade.

Worth noting: keep messages as a flat array, not nested threads. Live support chat isn't Slack. If you add threading later you can restructure, but starting nested will cost you twice the complexity for zero visible benefit in a standard bubble widget.

One more thing — generate your message IDs with crypto.randomUUID() (available in all modern browsers since 2022). Don't use Date.now() as an ID; if two messages arrive in the same millisecond you'll get duplicate React keys and subtle rendering bugs.

The Bubble Button Component

The bubble sits fixed at bottom: 24px; right: 24px — that 24px gap is almost universal and comes from Material Design's original FAB spec. Go lower and it clips on mobile browsers with nav bars. Go higher and it feels floaty.

Here's a minimal but solid implementation: ``tsx import { motion, AnimatePresence } from 'framer-motion'; type BubbleProps = { isOpen: boolean; unreadCount: number; onClick: () => void; }; export function ChatBubble({ isOpen, unreadCount, onClick }: BubbleProps) { return ( <motion.button onClick={onClick} className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center focus-visible:ring-2 focus-visible:ring-blue-400" whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.95 }} aria-label={isOpen ? 'Close chat' : 'Open chat'} aria-expanded={isOpen} > <AnimatePresence mode="wait" initial={false}> {isOpen ? ( <motion.span key="close" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.15 }} > ✕ </motion.span> ) : ( <motion.span key="chat" initial={{ rotate: 90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: -90, opacity: 0 }} transition={{ duration: 0.15 }} > 💬 </motion.span> )} </AnimatePresence> {unreadCount > 0 && !isOpen && ( <span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-xs flex items-center justify-center"> {unreadCount > 9 ? '9+' : unreadCount} </span> )} </motion.button> ); } ``

The icon swap with AnimatePresence gives you that polished rotate-in/rotate-out feel without any CSS hack. The mode="wait" makes the exit animation finish before the enter starts — critical here, otherwise both icons are briefly visible.

In practice, the aria-expanded attribute is what screen readers use to announce whether the panel is open. Don't skip it. Pair it with an aria-controls pointing to the message window's id and you've covered the baseline WCAG 2.1 requirement for expandable regions.

If your app uses glassmorphism components, you can give the bubble a backdrop-filter: blur(8px) treatment and a semi-transparent background instead of solid blue. Works especially well on dark or image-heavy layouts.

The Message Window

The window mounts/unmounts with an animation. Use AnimatePresence at the parent level and wrap the window in a motion.div. Scale from 0.9 to 1, fade in, origin at bottom-right — that's the standard chat panel entrance and it takes about 8 lines of Framer Motion config.

import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';

type MessageWindowProps = {
  messages: Message[];
  isTyping: boolean;
  inputValue: string;
  onInputChange: (v: string) => void;
  onSend: () => void;
};

export function MessageWindow({
  messages, isTyping, inputValue, onInputChange, onSend
}: MessageWindowProps) {
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, isTyping]);

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.9, y: 16 }}
      animate={{ opacity: 1, scale: 1, y: 0 }}
      exit={{ opacity: 0, scale: 0.9, y: 16 }}
      transition={{ type: 'spring', stiffness: 300, damping: 28 }}
      style={{ transformOrigin: 'bottom right' }}
      className="fixed bottom-24 right-6 z-50 w-80 h-[420px] rounded-2xl
                 bg-white dark:bg-zinc-900 shadow-2xl flex flex-col
                 overflow-hidden border border-zinc-200 dark:border-zinc-700"
      role="dialog"
      aria-label="Chat window"
      aria-live="polite"
    >
      {/* Header */}
      <div className="flex items-center gap-3 px-4 py-3 border-b
                      border-zinc-100 dark:border-zinc-800">
        <div className="w-8 h-8 rounded-full bg-blue-600 flex items-center
                        justify-center text-white text-sm font-bold">S</div>
        <div>
          <p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Support</p>
          <p className="text-xs text-green-500">Online</p>
        </div>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-2">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`max-w-[75%] rounded-2xl px-3 py-2 text-sm
              ${ msg.role === 'user'
                ? 'self-end bg-blue-600 text-white rounded-br-sm'
                : 'self-start bg-zinc-100 dark:bg-zinc-800 text-zinc-900
                   dark:text-zinc-100 rounded-bl-sm'
              }`}
          >
            {msg.text}
          </div>
        ))}
        {isTyping && <TypingIndicator />}
        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <form
        onSubmit={(e) => { e.preventDefault(); onSend(); }}
        className="px-4 py-3 border-t border-zinc-100 dark:border-zinc-800
                   flex gap-2"
      >
        <input
          type="text"
          value={inputValue}
          onChange={(e) => onInputChange(e.target.value)}
          placeholder="Type a message..."
          className="flex-1 text-sm bg-zinc-100 dark:bg-zinc-800 rounded-full
                     px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500
                     text-zinc-900 dark:text-zinc-100"
        />
        <button
          type="submit"
          disabled={!inputValue.trim()}
          className="w-9 h-9 rounded-full bg-blue-600 text-white
                     disabled:opacity-40 flex items-center justify-center"
        >
          ↑
        </button>
      </form>
    </motion.div>
  );
}

That aria-live="polite" on the dialog means screen readers will announce new messages without interrupting whatever the user is currently doing. Use polite, not assertiveassertive interrupts the user mid-sentence and is almost always wrong for chat.

The scrollIntoView in the useEffect auto-scrolls to the latest message. Hook it to both messages and isTyping so the view scrolls when the typing indicator appears too. That small detail prevents the indicator from hiding offscreen.

Quick aside: the w-80 h-[420px] size (320px × 420px) fits comfortably above the fold on any viewport wider than 375px — the iPhone SE width from 2020. Below that you'd want a full-screen takeover instead of a floating panel.

Typing Indicator with Animated Dots

The three-dot typing animation is probably the most-copied UI pattern of the last decade. You've seen it in iMessage, WhatsApp, Slack. Recreating it in CSS takes about 15 lines. ``tsx function TypingIndicator() { return ( <div className="self-start flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 rounded-2xl rounded-bl-sm px-3 py-2"> {[0, 1, 2].map((i) => ( <motion.span key={i} className="w-2 h-2 rounded-full bg-zinc-400 block" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut', }} /> ))} </div> ); } ``

The stagger comes from delay: i * 0.15. Each dot starts 150ms after the previous one. The total animation cycle is 600ms. These numbers aren't arbitrary — they match the rhythm of real typing cadence closely enough that users read it as genuine activity.

Honestly, the dot size matters more than you'd expect. At w-2 h-2 (8px), the dots are visible but not distracting. Go up to 10px and the indicator starts looking clunky. Go down to 6px and it disappears on low-DPI screens.

That said, you don't want this indicator showing forever. Simulate a realistic agent response by toggling isTyping for 1.5–3 seconds before displaying the reply. Here's the pattern: ``tsx function simulateAgentReply(dispatch: React.Dispatch<Action>, replyText: string) { dispatch({ type: 'SET_TYPING', payload: true }); setTimeout(() => { dispatch({ type: 'SET_TYPING', payload: false }); dispatch({ type: 'ADD_MESSAGE', payload: { id: crypto.randomUUID(), role: 'agent', text: replyText, timestamp: Date.now(), }, }); }, 1800); // 1.8s feels natural } ``

1800ms is about right for a short reply. If the reply is long, push it to 2500ms. Users accept the delay — it actually builds trust because a zero-latency response feels like a bot.

Wiring It Together with useReducer

useState starts breaking down the moment you have four or five related state updates happening together. A single dispatch is cleaner. Here's the full reducer: ``tsx type Action = | { type: 'TOGGLE_OPEN' } | { type: 'SET_INPUT'; payload: string } | { type: 'SEND_MESSAGE' } | { type: 'ADD_MESSAGE'; payload: Message } | { type: 'SET_TYPING'; payload: boolean }; const initialState: ChatState = { isOpen: false, messages: [ { id: crypto.randomUUID(), role: 'agent', text: 'Hey there! How can I help you today?', timestamp: Date.now(), }, ], inputValue: '', isTyping: false, isMinimized: false, }; function chatReducer(state: ChatState, action: Action): ChatState { switch (action.type) { case 'TOGGLE_OPEN': return { ...state, isOpen: !state.isOpen }; case 'SET_INPUT': return { ...state, inputValue: action.payload }; case 'SEND_MESSAGE': if (!state.inputValue.trim()) return state; return { ...state, messages: [ ...state.messages, { id: crypto.randomUUID(), role: 'user', text: state.inputValue.trim(), timestamp: Date.now(), }, ], inputValue: '', }; case 'ADD_MESSAGE': return { ...state, messages: [...state.messages, action.payload] }; case 'SET_TYPING': return { ...state, isTyping: action.payload }; default: return state; } } ``

Then your root component becomes clean: ``tsx export function LiveChatWidget() { const [state, dispatch] = useReducer(chatReducer, initialState); const handleSend = () => { dispatch({ type: 'SEND_MESSAGE' }); // Replace with real WebSocket/API call: simulateAgentReply(dispatch, 'Thanks for reaching out! Let me check that for you.'); }; return ( <> <AnimatePresence> {state.isOpen && ( <MessageWindow messages={state.messages} isTyping={state.isTyping} inputValue={state.inputValue} onInputChange={(v) => dispatch({ type: 'SET_INPUT', payload: v })} onSend={handleSend} /> )} </AnimatePresence> <ChatBubble isOpen={state.isOpen} unreadCount={0} onClick={() => dispatch({ type: 'TOGGLE_OPEN' })} /> </> ); } ``

Worth noting: AnimatePresence needs to wrap the conditionally rendered component at the call site — not inside the component itself. If you move it inside MessageWindow, the exit animation will never fire because the component is already unmounted.

When you're ready to connect a real backend, handleSend is your single integration point. Replace simulateAgentReply with a WebSocket emit, a Supabase Realtime channel publish, or a fetch to your own API. The UI layer doesn't care which one you pick — and that's exactly how it should be.

Styling Options and Visual Themes

The code above uses a clean white/zinc palette that works for most SaaS products. But your app might live in a darker, more stylized world. If you're building something with a glassmorphism aesthetic, swap the solid bg-white for bg-white/10 backdrop-blur-md border-white/20 and the whole widget takes on that frosted-glass look.

For a more aggressive look, neobrutalism works surprisingly well for chat widgets — thick black borders, flat shadows, high-contrast message bubbles. The floating panel starts feeling less like a popup and more like a UI object. The box shadow generator is handy here for dialing in those offset shadows without guessing CSS values by hand.

Whatever visual direction you go, keep the message bubble contrast ratio above 4.5:1 against its background. At 12–14px font size (our bubble uses text-sm which is 14px in Tailwind's default scale), that contrast threshold is non-negotiable for WCAG AA compliance. The gradient generator has a live contrast checker baked in if you're going for gradient backgrounds on the bubble button.

One real decision you'll face: should the window be a portal? In most cases, yes. Rendering the widget via createPortal into document.body sidesteps any overflow: hidden or z-index stacking issues from parent containers. It's two extra lines and it saves you a debugging session when some ancestor div clips your widget.

If you want a pre-built component with all the accessibility and animation already wired up — including chat-adjacent patterns like notification banners and toasts — Empire UI has a growing library of production-ready pieces that use the same Tailwind + Framer Motion stack we've been using throughout this guide.

FAQ

Do I need Framer Motion to build a chat widget in React?

No, but it makes the bubble swap and window entrance animation dramatically easier. You can replicate the effects with pure CSS @keyframes and transition if you want zero dependencies, though the exit animations on unmount require extra work without a library.

How do I connect this widget to a real WebSocket backend?

Replace the simulateAgentReply call in handleSend with your WebSocket emit. Listen for incoming messages on socket.on('message', ...) and dispatch ADD_MESSAGE — the UI layer stays identical regardless of transport.

Why use useReducer instead of useState for this?

Four or more related state fields updating together (typing, messages, input, open state) become hard to reason about with separate setState calls. A reducer keeps all transitions explicit and prevents stale-closure bugs in async handlers.

Can I use this widget as a React Portal?

Yes, and it's recommended. Wrap the return of LiveChatWidget in createPortal(content, document.body) to avoid z-index and overflow clipping issues from ancestor containers. It has no effect on the component logic.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Chat UI in React: Message List, Input, Typing IndicatorChat Interface in React: Messages, Timestamps, Typing IndicatorWebSockets in React: Real-Time Chat, Notifications, Live DataWebRTC in React: Video Calls, Screen Share, Peer-to-Peer Data