AI

Chatbot Widget

An embeddable support chat widget — launcher bubble with an acknowledged unread badge, a panel that keeps its draft when minimised, quick replies, and a rating row on the way out.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CheckCheck,
  ChevronDown,
  CircleAlert,
  LoaderCircle,
  MessageCircle,
  RotateCcw,
  Send,
  Star,
  X,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chatbot-widget.json

Prompt

Build a React + TypeScript + Tailwind "ChatbotWidget" block (lucide-react
icons, zod for the contract, no other runtime dependency).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    config: { botName, tagline?, avatarUrl?, online: boolean, greeting,
              quickReplies: { id, label, text? }[], unreadCount: number,
              launcherLabel, placeholder, poweredBy?: { label, href } };
    messages: { id, from: "bot" | "user" | "system", text,
                at, delivery?: "sending" | "sent" | "failed" }[];
    typing?: boolean; errorMessage?: string }.
- Props = z.infer of that schema plus: open?, defaultOpen?, onOpenChange?,
  onSend (REQUIRED — a send button that goes nowhere is a dead affordance),
  onQuickReply?, onRetry?, onRetryMessage?, onEndChat?, onRate?, onRestart?,
  fullscreenOnNarrow? (default true), align? ("start" | "end", default "end"),
  panelClassName?, className, and the rest of a div's native props. forwardRef
  the root so the host can measure or portal it.
- `at` is a display-ready clock label ("14:32"), never a Date: formatting a
  timestamp inside the component makes the server and the client disagree
  about the timezone and hydrate differently.
- Positioning belongs to the host. The root is a plain anchor box; production
  passes className="fixed bottom-6 right-6 z-50", an embedded stage passes
  "absolute bottom-4 right-4". The component only decides which edge of the
  launcher the panel hangs from (align).

Behavior
- Open state is controlled-or-uncontrolled (`open ?? internal`), and every
  change goes through one setOpen that also emits onOpenChange.
- Launcher: a round primary bubble carrying aria-expanded plus aria-controls
  on the panel id, MessageCircle when closed and ChevronDown when open, with a
  one-shot entrance keyframe.
- Unread is acknowledged, never rewritten: the badge shows
  max(0, config.unreadCount - acknowledged); `acknowledged` is set when the
  panel opens and seeded at mount when it mounts open. The widget records what
  it opened past instead of pretending to know the server's read state, so a
  host re-pushing a stale count cannot resurrect a cleared badge. Cap the
  glyph at "9+" and put a ping ring behind it.
- Four first-class transcript branches:
  * loading — four skeleton bubbles that mirror the real anatomy on
    alternating sides, aria-hidden, with one sr-only "Loading the
    conversation"; composer disabled, no chips.
  * error — icon, errorMessage ?? generic copy, and "Try again" only when
    onRetry exists; the composer stays disabled so nothing is typed into a
    conversation that failed to load.
  * empty — greeting bubble plus one hint line that points at the chips (or at
    the composer when there are none).
  * ready — the message list.
- Message anatomy: bot bubbles left with a small avatar, user bubbles right in
  primary, and `system` rows as centred muted notes with no bubble at all.
  delivery drives the meta row: "sending" dims the bubble and spins a loader,
  "sent" shows a double check, "failed" tints the border, prints "Not
  delivered" and offers Retry only when onRetryMessage exists.
- Scroll follow: the transcript keeps a stick-to-bottom flag updated on every
  scroll event (distance to bottom below 48px). New messages, typing changes
  and status changes scroll to the bottom only while that flag is true, so a
  reader who scrolled up is never yanked back down. The first paint of an
  opened panel jumps instantly (scrollBehavior forced to auto for that single
  assignment, then handed back to CSS); later updates glide.
- Quick replies appear only while the ball is in the user's court: live status,
  chips configured, not typing, and the newest message is not the user's own.
  Picking one calls onQuickReply, or falls back to onSend(text ?? label).
- Composer: a real form so Enter submits, controlled input with an sr-only
  label, send disabled while the draft is blank or the status is not live. The
  draft lives in the widget root, above the panel, so minimising and reopening
  keeps the half-typed sentence — the panel unmounts, the draft does not.
- Ending a chat is a two-beat exit and a tiny state machine
  (phase: chat, rating, ended). "End chat" (rendered only when onEndChat
  exists) swaps the composer for the rating row, and that row IS the
  confirmation: a star calls onRate(1..5) then onEndChat, "Skip and end" ends
  with no score, "Keep chatting" returns to phase chat and calls nothing.
  A ref one-shot-locks the ending so a double click cannot end it twice. After
  ending, the transcript stays readable and "Start a new chat" appears only
  when onRestart exists; restarting clears the lock, the score and the phase.
- Escape anywhere inside the widget closes the panel and returns focus to the
  launcher, and stops propagation so a host dialog does not close with it. The
  root spreads the host's props, so its keydown handler must COMPOSE the
  consumer's onKeyDown (call it first, bail on defaultPrevented) instead of
  sitting in the JSX before the spread, where it would be silently replaced.
- Focus: opening focuses the composer, but never on first paint — a widget
  rendered open (defaultOpen, SSR) must not steal the caret from its page.
  Closing NOMINATES the launcher with a ref and hands the caret over from that
  same effect, one commit later: under the narrow takeover the launcher is
  display:none for as long as open is true, so focusing it inside the close
  handler is a silent no-op and the caret falls to <body> when the panel goes.
- Narrow viewports: the open panel takes the whole screen through max-sm:
  longhand overrides (fixed plus top/right/bottom/left 0, rounded-none), never
  a matchMedia listener — no breakpoint state, no hydration flash. Longhands,
  not an inset shorthand, because only a same-property variant reliably beats
  the docked bottom-full/right-0. The launcher hides underneath and the
  header's chevron swaps to an X, both by the same variant. Caveat worth
  keeping in the source: an ancestor with a transform or contain:paint breaks
  position:fixed, so a widget mounted inside one must portal its panel.

Rendering and styling
- Semantic tokens only: bg-card panel, bg-primary launcher and outgoing
  bubbles, bg-muted incoming bubbles and skeletons, text-muted-foreground for
  metadata, border for every divider, text-destructive for undelivered and for
  the load failure, ring for focus. No hex, rgb or oklch anywhere.
- Three keyframes ship with the component in a hoisted style element deduped
  by href: launcher pop-in, panel rise, typing dot. Every animated class
  carries motion-reduce:animate-none (motion-reduce:hidden for the ping ring),
  and the transcript pairs scroll-smooth with motion-reduce:scroll-auto — with
  motion off nothing moves and nothing breaks.
- A11y: the panel is role="dialog" with an accessible name; the transcript is
  role="log" aria-live="polite" and tabbable so it can be scrolled from the
  keyboard; every icon-only control has an aria-label; the launcher's label
  spells out the unread count in words; decorative dots and rings are
  aria-hidden.
- The avatar falls back to initials on error and also probes
  complete/naturalWidth in a ref callback, which catches an image that failed
  before hydration attached onError.
- cn() merges className on the root and panelClassName on the panel.

Customization levers
- Panel size: h-[26rem] w-[21rem] on the panel, overridable per instance via
  panelClassName; the max-h / max-w guards keep it inside small viewports.
- Corner: positioning comes from className; flip align to "start" for a
  left-hand launcher and the panel's origin flips with it.
- Fullscreen threshold: swap the max-sm: prefix for max-md:, or pass
  fullscreenOnNarrow={false} to keep the docked panel everywhere. It is one
  constant string, not logic.
- Sub-blocks: quick replies, powered-by, End chat, Retry and Start-a-new-chat
  each disappear by omitting their data or their handler — nothing is ever
  rendered as a control that does nothing.
- Rating scale: swap the five stars for two thumbs or a 1-10 row by editing one
  array; add a comment box by rendering it above the row and calling onRate
  from that form's submit instead of from the star click.
- Motion: raise the panel keyframe to a spring, or delete the entrance
  keyframes entirely — nothing functional depends on them.
- Density: bubble max-width (85%), the 48px stick threshold and the skeleton
  row count are the three numbers worth tuning per brand.

Concepts

  • Acknowledged unread — the badge is unreadCount − acknowledged, and opening the panel is the acknowledgement. The widget never zeroes the host's number, so a stale count pushed again cannot make a badge the user already cleared come back.
  • Stick-to-bottom follow — an incoming message scrolls into view only while the reader is parked at the bottom; scrolling up to re-read pins the view until they come back. Auto-scroll that ignores this is the single most common chat-widget defect.
  • Draft outlives the panel — the composer's text lives in the widget root, not in the panel, so minimising unmounts the panel without eating the half-typed sentence.
  • Chips as a turn signal — quick replies are an answer to the bot, so they show only when the newest message is not the user's own and nothing is being typed; picking one sends and they retire until the bot speaks again.
  • The rating row is the confirmation — "End chat" does not end anything; it arms the exit. One star rates and ends, "Skip and end" ends without a score, "Keep chatting" backs out, and a ref lock makes ending unrepeatable.
  • Contract-driven four states — a widget hangs on a support backend that can be slow, empty or down, so loading / empty / error / ready are branches of the transcript rather than a spinner bolted on afterwards.

On This Page