react-agent-widget
v0.2.4
Published
Drop-in React chatbot and AI agent widget — streaming chat UI for OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, or any custom API. Fully themeable, TypeScript-first, zero CSS-in-JS.
Maintainers
Readme
react-agent-widget
→ Live demo on CodeSandbox — no install, no API key, works in your browser.
The fastest way to add a production-ready AI chatbot to any React app.
Drop in one component. Connect to OpenAI, Anthropic Claude, AWS Bedrock, Azure OpenAI, or your own API. Get a streaming chat UI with a floating launcher button, full theming, and zero style conflicts — in under 10 lines of code.
Why react-agent-widget?
| Feature | react-agent-widget |
|---|---|
| Works with any AI provider | OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, custom HTTP |
| Streaming responses | Built-in SSE parser, token-by-token rendering |
| File & image upload | Images, videos, and files — thumbnails in chat, base64 sent to your adapter |
| Voice input | One-prop mic button, live transcription, any language via Web Speech API |
| Fully themeable | CSS custom properties — change every color, radius, shadow, font |
| Generative UI | Agent renders React components (cards, forms, charts) inside the chat |
| TypeScript-first | Full type safety, no any in the public API |
| Zero CSS conflicts | Scoped raw- class prefix, no global styles, no CSS-in-JS runtime |
| Headless mode | Use your own UI with the built-in state/streaming hooks |
| Lightweight | ~44 KB minified ESM, React 18 peer dep, no mandatory runtime deps |
| SSR-safe | No window access at render time — works with Next.js App Router |
Installation
npm install react-agent-widget
# or
yarn add react-agent-widget
# or
pnpm add react-agent-widgetRequires React 18+ as a peer dependency.
Quick start — 10 lines
import { AgentWidget, createHttpAdapter } from 'react-agent-widget'
const adapter = createHttpAdapter({ url: '/api/chat' })
export default function App() {
return (
<AgentWidget
adapter={adapter}
welcomeMessage="Hi! How can I help you today?"
/>
)
}Your /api/chat endpoint receives { messages, stream } and returns a streaming SSE response or plain JSON. No API keys go in the browser — your server adds credentials.
That's it. A floating chat button appears at the bottom-right of the page.
Connecting to AI providers
All adapters point at a proxy endpoint you control on your server. Your server adds the API key, SigV4 signature, or managed identity — never the browser.
OpenAI / ChatGPT
import { AgentWidget, createOpenAIAdapter } from 'react-agent-widget'
<AgentWidget
adapter={createOpenAIAdapter({
proxyUrl: '/api/openai', // your server forwards to api.openai.com + adds Authorization header
model: 'gpt-4o',
})}
/>Anthropic Claude
import { AgentWidget, createAnthropicAdapter } from 'react-agent-widget'
<AgentWidget
adapter={createAnthropicAdapter({
proxyUrl: '/api/anthropic', // your server adds x-api-key + Anthropic-Version headers
model: 'claude-3-5-sonnet-20241022',
})}
/>AWS Bedrock
import { AgentWidget, createBedrockAdapter } from 'react-agent-widget'
<AgentWidget
adapter={createBedrockAdapter({
proxyUrl: '/api/bedrock', // your server adds SigV4 auth
model: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
})}
/>Azure OpenAI
import { AgentWidget, createAzureOpenAIAdapter } from 'react-agent-widget'
<AgentWidget
adapter={createAzureOpenAIAdapter({
proxyUrl: '/api/azure', // your server adds api-key / managed identity
deploymentName: 'gpt-4o',
})}
/>Custom HTTP endpoint / BFF
import { AgentWidget, createHttpAdapter } from 'react-agent-widget'
<AgentWidget
adapter={createHttpAdapter({
url: '/api/chat',
// optional: customize the request body
formatRequest: (messages, options) => ({
history: messages,
config: { stream: true },
}),
})}
/>Full API
<AgentWidget
// required
adapter={adapter}
// launcher button
launcher={{
position: { side: 'right' }, // 'left' | 'right'
label: 'Chat with AI',
icon: <MyIcon />, // custom icon
badge: 3, // unread count bubble
}}
// theme — override any token
theme={{
mode: 'dark', // 'light' | 'dark'
colors: {
primary: '#7c3aed',
userBubble: '#7c3aed',
background: '#09090b',
},
radius: { panel: '20px', launcher: '12px' },
}}
// per-slot class names (add your own CSS on top)
classNames={{
panel: 'my-panel',
messageUser: 'my-user-msg',
messageAgent: 'my-agent-msg',
composer: 'my-composer',
}}
// chat behavior
welcomeMessage="Hi! How can I help?"
suggestedPrompts={['Summarize this page', 'Help me write an email']}
systemPrompt="You are a helpful assistant for Acme Corp."
placeholder="Ask me anything…"
renderMarkdown={true} // render markdown in agent replies (default: true)
// generative UI — agent renders React components
components={{
weatherCard: WeatherCard,
approvalPrompt: ApprovalPrompt,
}}
// events
onMessage={(msg) => analytics.track('chat_message', msg)}
onError={(err) => errorReporter.capture(err)}
onOpen={() => analytics.track('chat_opened')}
onClose={() => analytics.track('chat_closed')}
// file & image upload
upload={{
accept: 'image/*,video/*,.pdf,.docx', // passed to <input accept>
maxFiles: 5, // default: 5
maxFileSizeBytes: 10 * 1024 * 1024, // 10 MB limit per file
}}
// voice input (Web Speech API — no external dependency)
voice={{
// lang omitted → resolves to navigator.language automatically
// lang: 'en-US' explicit BCP-47 tag to override auto-detection
}}
// controlled open state (optional)
open={isOpen}
onOpenChange={setIsOpen}
/>Theming
The widget uses CSS custom properties for all visual tokens. Pass a theme object — the widget applies the values as inline CSS variables on its root element, so your host-app styles are never affected.
// Dark purple theme example
<AgentWidget
adapter={adapter}
theme={{
mode: 'dark',
colors: {
primary: '#8b5cf6',
primaryForeground: '#ffffff',
background: '#09090b',
surface: '#18181b',
userBubble: '#8b5cf6',
agentBubble: '#27272a',
agentBubbleForeground: '#fafafa',
},
radius: {
panel: '24px',
message: '20px',
launcher: '16px',
},
}}
/>Available CSS variables (for stylesheet overrides)
/* Colors */
--raw-color-primary --raw-color-primary-fg
--raw-color-background --raw-color-surface --raw-color-surface-fg
--raw-color-border --raw-color-muted --raw-color-muted-fg
--raw-color-error --raw-color-error-fg
--raw-color-user-bubble --raw-color-user-bubble-fg
--raw-color-agent-bubble --raw-color-agent-bubble-fg
/* Typography */
--raw-font-family --raw-font-size-sm --raw-font-size-md --raw-font-size-lg
/* Radius */
--raw-radius-panel --raw-radius-message
--raw-radius-launcher --raw-radius-composer
/* Shadow / z-index */
--raw-shadow-panel --raw-shadow-launcher
--raw-z-panel --raw-z-launcherGenerative UI
Let the AI render interactive React components directly inside the chat — not just text.
// 1. Define your component
function WeatherCard({ payload, onResult }) {
const { city, temperature, condition } = payload.props
return (
<div style={{ padding: 16, borderRadius: 12, background: '#f0f9ff' }}>
<h3>{city} — {temperature}°C, {condition}</h3>
<button onClick={() => onResult?.({ action: 'show_forecast', city })}>
Show 7-day forecast
</button>
</div>
)
}
// 2. Register it
<AgentWidget
adapter={adapter}
components={{ weatherCard: WeatherCard }}
/>// 3. Your agent/tool emits this SSE event:
data: {"type":"component","payload":{"type":"weatherCard","props":{"city":"London","temperature":15,"condition":"Cloudy"}}}The widget renders <WeatherCard> in-stream. When the user clicks the button, onResult fires and the widget sends the result back into the conversation automatically.
File & image upload
Enable the paperclip button in the composer with the upload prop.
<AgentWidget
adapter={adapter}
upload={{
accept: 'image/*,video/*,.pdf,.docx,.txt',
maxFiles: 5,
maxFileSizeBytes: 10 * 1024 * 1024, // 10 MB
}}
/>What the user sees: a paperclip icon in the composer. Selecting files shows a preview strip — images as thumbnails, videos and documents as chips with name and size. Each attachment has an ✕ button to remove it before sending.
What your adapter receives: message.attachments on every UserMessage, typed as MessageAttachment[]:
interface MessageAttachment {
id: string
name: string
size: number
mimeType: string
kind: 'image' | 'video' | 'file'
dataUrl?: string // base64 data URL for files ≤ 10 MB
}Forward dataUrl directly to a multimodal model (GPT-4o Vision, Claude, etc.):
// In your /api/chat proxy:
const { messages } = await req.json()
const openaiMessages = messages.map(msg => {
if (msg.role !== 'user' || !msg.attachments?.length) return msg
return {
role: 'user',
content: [
{ type: 'text', text: msg.content },
...msg.attachments
.filter(a => a.kind === 'image' && a.dataUrl)
.map(a => ({
type: 'image_url',
image_url: { url: a.dataUrl },
})),
],
}
})In the chat bubble: images render as <img>, videos as <video controls>, other files as a filename chip.
Voice input
Enable the mic button with the voice prop. Uses the browser's Web Speech API — no external library, no API key, no extra bundle size.
<AgentWidget
adapter={adapter}
voice={{}} // lang omitted → auto-detected from navigator.language
// voice={{ lang: 'en-US' }} explicit BCP-47 tag for a specific language
/>How it works:
- A mic button appears next to the composer input
- Click to start — the button pulses red, shows a crossed-out mic icon, the textarea border turns red, placeholder changes to "Listening…"
- As you speak, partial words appear below the textarea in real time
- Each finalised phrase is appended to the textarea
- Click the mic again (crossed-out icon) to stop recording without sending, or press Enter / click Send to stop and submit immediately
Language detection resolves in this priority order:
| Priority | Source | Example |
|---|---|---|
| 1 | Explicit lang prop | lang: 'fr-FR' |
| 2 | navigator.language (browser/OS language) | 'en-GB', 'ja-JP' |
| 3 | Browser recognition service default | — |
Omitting lang (or setting it to '') automatically uses the browser's configured language, so it works correctly for users around the world without any extra configuration.
For a fixed language, pass a BCP-47 tag:
voice={{ lang: 'fr-FR' }} // French
voice={{ lang: 'ja-JP' }} // Japanese
voice={{ lang: 'ar-SA' }} // Arabic
voice={{ lang: 'zh-CN' }} // MandarinHeadless usage — use the hook directly in your own UI:
import { useSpeechRecognition } from 'react-agent-widget'
function MyMicButton() {
const { isListening, isSupported, interimTranscript, start, stop } =
useSpeechRecognition({
// lang omitted → resolves to navigator.language automatically
onFinalTranscript: (text) => console.log('said:', text),
})
if (!isSupported) return null
return (
<button onClick={isListening ? stop : start}>
{isListening ? `Hearing: ${interimTranscript}` : 'Speak'}
</button>
)
}Browser support: Chrome, Edge, and Safari 14.1+. The mic button is automatically hidden on browsers that don't support the API.
Headless mode
Bring your own UI — use just the state management and streaming logic.
import {
WidgetProvider,
useWidget,
ThemeProvider,
createOpenAIAdapter,
} from 'react-agent-widget'
const adapter = createOpenAIAdapter({ proxyUrl: '/api/openai' })
function MyChatUI() {
const { messages, sendMessage, isStreaming, isOpen, setIsOpen } = useWidget()
return (
<div>
{messages.map(msg => (
<div key={msg.id} className={msg.role}>
{msg.content}
</div>
))}
<input
onKeyDown={(e) => {
if (e.key === 'Enter') void sendMessage(e.currentTarget.value)
}}
/>
</div>
)
}
export function App() {
return (
<ThemeProvider>
<WidgetProvider adapter={adapter}>
<MyChatUI />
</WidgetProvider>
</ThemeProvider>
)
}Writing a proxy endpoint
Your backend receives the messages and forwards them to the AI provider. Here is a minimal Next.js example for OpenAI:
// app/api/openai/route.ts
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function POST(req: Request) {
const { messages } = await req.json()
const stream = openai.beta.chat.completions.stream({
model: 'gpt-4o',
messages,
})
return new Response(stream.toReadableStream(), {
headers: { 'Content-Type': 'text/event-stream' },
})
}The widget's SSE parser understands OpenAI's native streaming format automatically.
SSE format (custom backends)
If you write your own backend, use this simple format:
data: {"type":"text-delta","delta":"Hello"}
data: {"type":"text-delta","delta":", world!"}
data: {"type":"component","payload":{"type":"weatherCard","props":{...}}}
data: {"type":"done"}Or just return data: [DONE] as the final event (OpenAI convention — also understood).
Building a custom Adapter
Implement the Adapter interface to connect any AI service:
import type { Adapter } from 'react-agent-widget'
export const myAdapter: Adapter = {
name: 'my-provider',
capabilities: { streaming: true, tools: false, multimodal: false },
async *send(messages, options) {
const res = await fetch('/api/my-llm', {
method: 'POST',
body: JSON.stringify({ messages }),
signal: options?.signal,
})
// yield chunks…
yield { type: 'text-delta', delta: 'Hello' }
yield { type: 'done' }
},
}TypeScript
Full type safety — no any in the public API.
import type {
Adapter,
AdapterCapabilities,
StreamChunk,
Message,
UserMessage,
AssistantMessage,
MessageAttachment,
Theme,
ClassNamesConfig,
AgentWidgetProps,
GenerativeUIComponent,
UploadConfig,
VoiceConfig,
SendMessageOptions,
} from 'react-agent-widget'Accessibility
- Keyboard navigable — Tab, Enter, Escape to close, full focus management
- ARIA:
role="dialog",aria-modal,aria-live="polite"on message list - Respects
prefers-reduced-motion(animations disabled automatically) - Default theme meets WCAG 2.1 AA contrast ratios
Browser & framework support
| Environment | Supported | |---|---| | React | 18+ | | Next.js (App Router) | Yes — SSR-safe | | Next.js (Pages Router) | Yes | | Vite | Yes | | Remix | Yes | | Webpack | Yes | | Browsers | All modern evergreen (Chrome, Firefox, Safari, Edge) |
License
MIT — TilangaPramith
