reactuibotwidget
v0.2.0
Published
A small, dependency-free floating chatbot widget for React.
Maintainers
Readme
reactuibotwidget
Floating chatbot widget for React. Replies can be plain text or rich parts — product cards, quick-reply chips, status cards, approve/reject gates — rendered by components you supply. Styles included, React is a peer dependency.
Install
npm install reactuibotwidgetBasic usage
Add it once near the root of your app. It pins itself to the bottom-right corner.
import { ChatBot } from 'reactuibotwidget'
async function sendToBackend(message) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
})
const { reply } = await res.json()
return reply
}
export default function App() {
return <ChatBot onSendMessage={sendToBackend} />
}Backend — return { reply: "..." }:
app.post('/api/chat', (req, res) => {
res.json({ reply: `You said: ${req.body.message}` })
})User message → onSendMessage → your API → widget shows the reply. If
onSendMessage throws, the error text appears in the thread as a bot message.
No CSS import needed.
Rich replies
Instead of a string, return an array of parts:
[
{ "type": "text", "text": "Here are three picks:" },
{ "type": "products", "items": [{ "id": "sku-1", "name": "Trail Runner" }] }
]Then tell the widget which component renders each type:
<ChatBot
onSendMessage={sendToBackend}
components={{ products: ProductList }}
/>{ type: 'text' } is built in. Every other type is looked up in components
and rendered full-width, receiving the part's own fields plus send. A type
with no registered component renders a visible warning instead of failing
silently.
Parts are plain JSON on purpose — a React component can't survive an HTTP round
trip, but { type: 'products', items: [...] } can. That's what lets your server
decide what the user sees.
send(input, label)
Every registered component gets a send function to start the next turn.
send('Track my order') // acts like the user typed it
send({ action: 'add_to_cart', id: 'sku-1' }, 'Add') // payload; thread shows "Add"
send({ action: 'ping' }, null) // sends without a user bubbleinputreachesonSendMessageunchanged — so a button can send an object, not just textlabelis what appears in the thread. Omit it to reuse a stringinput; passnullto send silently
Because input may be an object, handle both on the way in:
async function sendToBackend(input) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(typeof input === 'string' ? { message: input } : input),
})
return (await res.json()).reply // a string, a part, or an array of parts
}Example: quick-reply chips
Server returns:
[
{ "type": "text", "text": "How can I help?" },
{ "type": "choices", "options": ["Track my order", "I want a refund"] }
]Component — sends the label as if the user typed it, then hides itself so old chips can't be clicked later:
import { useState } from 'react'
function Choices({ options, send }) {
const [used, setUsed] = useState(false)
if (used) return null
return (
<div className="choices">
{options.map((o) => (
<button key={o} onClick={() => { setUsed(true); send(o) }}>
{o}
</button>
))}
</div>
)
}Register it: components={{ choices: Choices }}
Example: product cards
Any custom component works the same way — the widget knows nothing about products, it just hands the part's fields to your component.
{
"type": "products",
"items": [
{ "id": "sku-1", "name": "Trail Runner", "price": "₹4,499", "emoji": "👟" }
]
}function ProductList({ items, send }) {
return (
<div className="cards">
{items.map((p) => (
<article key={p.id} className="card">
<span>{p.emoji}</span>
<div>
<h4>{p.name}</h4>
<p>{p.price}</p>
</div>
<button onClick={() => send({ action: 'add_to_cart', id: p.id }, `Add ${p.name}`)}>
Add
</button>
</article>
))}
</div>
)
}The button sends a payload, so your backend gets { action: 'add_to_cart',
id: 'sku-1' } rather than having to parse a sentence.
Style these however you like — the widget applies no styling to custom parts, so they inherit your app's design.
Example: status cards
{
"type": "order",
"id": "#1042",
"status": "Shipped",
"eta": "Arrives Fri",
"steps": ["Placed", "Packed", "Shipped", "Delivered"]
}function OrderCard({ id, status, eta, steps, send }) {
const reached = steps.indexOf(status)
return (
<article className="card">
<header>
<strong>Order {id}</strong> <span>{eta}</span>
</header>
<ol className="trail">
{steps.map((s, i) => (
<li key={s} className={i <= reached ? 'done' : ''}>{s}</li>
))}
</ol>
<button onClick={() => send('I want a refund', 'Request a refund')}>
Request a refund
</button>
</article>
)
}Example: approve / reject gates
For anything you don't want run without a human saying yes — refunds, deletes, sending an email — have the server return an approval part instead of performing the action:
[
{ "type": "text", "text": "I can do that, but it needs confirmation." },
{
"type": "approval",
"toolCallId": "call_refund_1042",
"summary": "Refund ₹4,499 for order #1042?",
"detail": "Goes back to the original payment method in 3–5 days."
}
]function Approval({ summary, detail, toolCallId, send }) {
const [decision, setDecision] = useState(null)
function answer(approved) {
setDecision(approved) // lock the card
send({ action: 'approval', toolCallId, approved },
approved ? 'Approve' : 'Reject')
}
if (decision !== null) return <p>{decision ? '✅ Approved' : '❌ Rejected'}</p>
return (
<article className="card">
<strong>{summary}</strong>
<p>{detail}</p>
<button onClick={() => answer(true)}>Approve</button>
<button onClick={() => answer(false)}>Reject</button>
</article>
)
}Keep the answered state in the component (decision above) so an old card can't
be clicked twice.
Backend resumes or cancels using toolCallId:
app.post('/api/chat', async (req, res) => {
const { action, toolCallId, approved } = req.body
if (action === 'approval') {
if (!approved) return res.json({ reply: 'No problem — I cancelled it.' })
const result = await runPendingTool(toolCallId) // now it actually runs
return res.json({ reply: `Done. ${result}` })
}
// ...normal message handling
})Tool calling
The tool loop belongs on your server, not in the widget:
- Server sends the conversation to your model with your tool definitions
- Model asks for a tool
- Server runs it — unless it's sensitive, in which case it returns an
approvalpart and parks the call - Server turns results into parts and returns them
- Widget renders them; button clicks come back as the next turn
The widget only renders parts and ships payloads, so it stays the same whichever model or framework you use.
Call your model provider from your server, never from
onSendMessage— that function runs in the browser, where an API key is visible in devtools.
Props
| Prop | Default | |
|---|---|---|
| onSendMessage | — | required; (input) => Reply \| Promise<Reply>. input is a string or a payload from send. Reply is a string, a part, or an array of parts |
| components | {} | maps a part type to the component that renders it |
| title | 'Chat Assistant' | header text |
| welcomeMessage | 'Hi! How can I help you today?' | first bot message; '' for none |
| placeholder | 'Type a message…' | input placeholder |
| accentColor | '#4f46e5' | any CSS color — header, bubbles, buttons |
A bare string reply still works everywhere, so code written against the earlier version needs no changes.
Open the bubble and try hello, show me products, track my order, or
I want a refund — one for each part type above. Source is in demo/.
