ComponentsChatBubble
ChatBubble
A quiet presence in the corner. A complete conversation, one click away.
On this page
The launcher stays in the viewport as you scroll. Open it, send a message, then minimize and reopen without losing your place.
A conversation, within reach
A working local simulation with streaming, stop and retry, attachments, and preserved drafts. Nothing is uploaded.
Good conversations
start with a hello.
A quiet presence in the corner. A helpful conversation, one click away.
Show sourceexamples/chat-bubble/basic.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowUpRight, MessageCircle, Sparkles } from "lucide-react";
import {
Button,
ChatBubble,
type ChatAttachment,
type ChatMessageData,
type ChatPrompt,
type ChatRun,
type ChatBubblePosition,
} from "@dethink/components";
const conversationId = "chat-bubble-demo";
const response =
"Absolutely. Start with one small step: choose a conversation your customers have every day, and make that experience feel effortless. You can customize this widget’s appearance and connect it to your own support team or AI service.";
/** Local simulation only. Replace these handlers with your application's transport. */
export function ChatBubbleBasic() {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<ChatBubblePosition>("bottom-right");
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [run, setRun] = useState<ChatRun>();
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [unread, setUnread] = useState(0);
const [failNext, setFailNext] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const openRef = useRef(open);
useEffect(() => {
openRef.current = open;
}, [open]);
useEffect(
() => () => {
if (timer.current) clearInterval(timer.current);
},
[],
);
function stream() {
if (timer.current) clearInterval(timer.current);
const id = crypto.randomUUID();
let cursor = 0;
setRun({ id, conversationId, status: "submitted", label: "Thinking…" });
timer.current = setInterval(() => {
cursor = Math.min(cursor + 12, response.length);
const complete = cursor === response.length;
setRun({
id,
conversationId,
status: complete ? "completed" : "running",
label: complete ? "Response complete" : "Writing…",
messageId: id,
});
setMessages((current) => {
const reply: ChatMessageData = {
id,
conversationId,
role: "assistant",
name: "Dethink",
status: complete ? "complete" : "streaming",
parts: [
{ id: "text", type: "text", text: response.slice(0, cursor) },
],
};
return current.some((message) => message.id === id)
? current.map((message) => (message.id === id ? reply : message))
: [...current, reply];
});
if (complete) {
if (timer.current) clearInterval(timer.current);
timer.current = null;
if (!openRef.current) setUnread((count) => count + 1);
}
}, 100);
}
function send(prompt: ChatPrompt) {
if (failNext) {
setFailNext(false);
throw new Error(
"Demo connection interrupted. Your draft is safe — send again to retry.",
);
}
setMessages((current) => [
...current,
{
id: crypto.randomUUID(),
conversationId,
role: "user",
name: "You",
createdAt: new Date().toISOString(),
parts: [
...(prompt.text
? [{ id: "text", type: "text" as const, text: prompt.text }]
: []),
...prompt.attachments.map((attachment) => ({
id: attachment.id,
type: "attachment" as const,
attachment,
})),
],
},
]);
setAttachments([]);
stream();
}
function changeOpen(next: boolean) {
setOpen(next);
if (next) setUnread(0);
}
return (
<div className="w-full" data-slot="chat-bubble-demo">
<div className="bg-muted/30 border-border relative overflow-hidden rounded-2xl border p-6 sm:p-10">
<div className="text-muted-foreground mb-8 flex items-center gap-2 text-xs font-medium tracking-widest uppercase">
<span className="bg-primary size-1.5 rounded-full" /> A little closer
to your customers
</div>
<div className="max-w-md">
<h3 className="text-foreground text-3xl font-semibold tracking-tight sm:text-4xl">
Good conversations
<br />
start with a hello.
</h3>
<p className="text-muted-foreground mt-4 max-w-sm text-sm leading-6">
A quiet presence in the corner. A helpful conversation, one click
away.
</p>
<Button
className="mt-6"
onClick={() => changeOpen(true)}
rightIcon={<ArrowUpRight aria-hidden="true" />}
>
Try the chat
</Button>
</div>
<div className="border-border mt-10 flex flex-wrap items-center gap-3 border-t pt-5">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
Position
<select
aria-label="Chat position"
value={position}
onChange={(event) =>
setPosition(event.target.value as ChatBubblePosition)
}
className="border-border bg-background text-foreground rounded-lg border px-2 py-1.5"
>
<option value="bottom-right">Bottom right</option>
<option value="bottom-left">Bottom left</option>
</select>
</label>
<Button
size="sm"
variant="outline"
onClick={() => setFailNext(true)}
disabled={failNext}
>
{failNext ? "Next send will fail" : "Simulate a send failure"}
</Button>
<span className="text-muted-foreground text-xs">
Local demo · no messages or files are uploaded
</span>
</div>
</div>
<ChatBubble
open={open}
onOpenChange={changeOpen}
position={position}
offset="1rem"
triggerProps={{
unreadCount: unread,
"aria-label": "Chat with Dethink",
}}
contentProps={{
title: "Dethink",
subtitle: "A little help, right here",
footer: "Demo conversation · sample responses",
}}
chat={{
conversationId,
messages,
run,
scroll: { contentClassName: "px-4 sm:px-4" },
className: "[&>div:last-of-type]:px-4 [&>div:last-of-type]:sm:px-4",
emptyState: (
<div className="flex min-h-52 flex-col items-start justify-center gap-4 py-5">
<div className="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-2xl">
<Sparkles aria-hidden="true" className="size-5" />
</div>
<div>
<h3 className="text-xl font-semibold tracking-tight">
Hi there. What’s on your mind?
</h3>
<p className="text-muted-foreground mt-2 text-sm leading-6">
Ask a question, share an idea, or tell us what you’re working
on.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setDraft("How can I get started?")}
leftIcon={<MessageCircle aria-hidden="true" />}
>
Help me get started
</Button>
</div>
),
children:
run?.status === "stopped" || run?.status === "error" ? (
<Button size="sm" variant="outline" onClick={stream}>
Try again
</Button>
) : null,
messageActions: {
onCopy: (text) => navigator.clipboard.writeText(text),
},
prompt: {
value: draft,
onValueChange: setDraft,
onSend: send,
placeholder: "Write a message…",
maxHeight: 112,
onStop: () => {
if (timer.current) clearInterval(timer.current);
timer.current = null;
setRun((current) =>
current
? {
...current,
status: "stopped",
label: "Stopped · partial response saved",
}
: current,
);
setMessages((current) =>
current.map((message) =>
message.status === "streaming"
? { ...message, status: "stopped" }
: message,
),
);
},
attachments,
allowAttachmentOnly: true,
fileRules: { maxFiles: 3, maxFileSize: 5 * 1024 * 1024 },
onFiles: (files) =>
setAttachments((current) => [
...current,
...files.map((file) => ({
id: crypto.randomUUID(),
name: file.name,
mediaType: file.type || "application/octet-stream",
size: file.size,
status: "ready" as const,
})),
]),
onAttachmentRemove: (attachment) =>
setAttachments((current) =>
current.filter((item) => item.id !== attachment.id),
),
},
}}
/>
</div>
);
}Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Lucide
(lucide-react)React SVG icons used in component controls and status indicators.
- Motion for React
(motion)Animations, transitions, and reduced-motion handling.
- React Aria
(react-aria)Accessibility hooks and overlay utilities used by the implementation.
- React DOM
(react-dom)Portals for rendering overlay content outside the normal component tree.
- React Aria Components
(react-aria-components)· via shared Dethink componentsAccessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { ChatBubble } from "@dethink/components";
// messages and sendMessage belong to your application's chat integration.
<ChatBubble
contentProps={{ title: "Your team", subtitle: "Here to help" }}
chat={{
conversationId: "support",
messages,
prompt: { onSend: sendMessage },
}}
/>ChatBubble owns placement and open state. ChatBubbleTrigger opens it, ChatBubbleContent is a non-modal dialog, and ChatBubbleClose minimizes it.
Pass chat for the ready-to-use composition, or supply children to replace the parts. Content stays mounted while closed, preserving composer and scroll state. Render one trigger and one content per root. Put application state above the widget if it must survive removing the entire widget or changing routes.
Make it your own
Custom support branding, a left-hand launcher, and independent open state.
A wider support panel on the left, with a custom header and launcher. Enable it to compare two independent widgets.
Show sourceexamples/chat-bubble/custom.tsx
"use client";
import { useState } from "react";
import { Headphones } from "lucide-react";
import {
Button,
Chat,
ChatBubble,
ChatBubbleClose,
ChatBubbleContent,
ChatBubbleTrigger,
} from "@dethink/components";
export function ChatBubbleCustom() {
const [enabled, setEnabled] = useState(false);
return (
<div className="space-y-4">
<p className="text-muted-foreground text-sm leading-6">
A wider support panel on the left, with a custom header and launcher.
Enable it to compare two independent widgets.
</p>
<Button variant="outline" onClick={() => setEnabled(!enabled)}>
{enabled ? "Remove support widget" : "Enable support widget"}
</Button>
{enabled && (
<ChatBubble
position="bottom-left"
width="28rem"
height="32rem"
offset="1rem"
defaultOpen
>
<ChatBubbleTrigger aria-label="Open support" unreadCount={2}>
<Headphones aria-hidden="true" className="size-6" />
</ChatBubbleTrigger>
<ChatBubbleContent
aria-label="Customer support"
header={
<div className="bg-primary text-primary-foreground flex items-center gap-3 px-5 py-4">
<Headphones aria-hidden="true" className="size-5" />
<div className="flex-1">
<h2 className="text-sm font-semibold">Customer support</h2>
<p className="mt-1 text-xs opacity-80">We’re here to help</p>
</div>
<ChatBubbleClose
aria-label="Minimize support"
className="hover:bg-primary-foreground/10"
/>
</div>
}
footer="Your team. Your conversation."
>
<Chat
conversationId="support-example"
messages={[]}
emptyState={
<p className="text-muted-foreground py-8 text-sm">
Connect this panel to your support service. This preview shows
a read-only empty state.
</p>
}
/>
</ChatBubbleContent>
</ChatBubble>
)}
</div>
);
}| Prop | What it does | Default |
|---|---|---|
open / defaultOpen / onOpenChangeboolean / boolean / (open: boolean) => void | Controlled or uncontrolled visibility. Closing preserves children and does not stop a host-owned response. | false |
chatChatProps | Existing Chat configuration for the default composition: conversationId, messages, prompt, run, activity, renderPart, messageActions, scroll and history. | Not set |
childrenReactNode | Replaces the default trigger and content. Compose ChatBubbleTrigger, ChatBubbleContent and ChatBubbleClose with custom content. | Default composition |
position"bottom-right" | "bottom-left" | Physical screen corner. Explicitly choose bottom-left in RTL if desired. | "bottom-right" |
offset / width / heightnumber | string | Pixels for numbers, CSS lengths for strings. The panel is bounded by the viewport and safe areas. | "1.5rem" / "25rem" / "36rem" |
zIndexnumber | Widget stacking order. Coordinate with application banners and overlays. | 50 |
motion"auto" | "none" | Auto respects reduced motion. None disables the transition. | "auto" |
triggerPropsChatBubbleTriggerProps | Native button props, custom icon children, aria-label, unreadCount and localized unreadLabel. The host owns unread counting. | Not set |
contentPropsChatBubbleContentProps | title, subtitle, avatar, header, footer, closeLabel, initialFocusRef, native div props and styles. A custom header needs its own accessible label and ChatBubbleClose. | Not set |
className / stylestring / CSSProperties | Customize the portal's fixed wrapper; style can override --chat-bubble-offset, --chat-bubble-width and --chat-bubble-height. Use triggerProps/contentProps for surface styles. | Not set |
ChatBubble reuses the existing Chat API and does not make network requests.
Supply messages and prompt.onSend. Returning false or rejecting a send preserves the draft; accepting clears it. Use run and activity for status, custom retry actions for recovery, and prompt.onStop for cancellation. Closing the panel does not stop streaming. Optional prompt attachments and messageActions use the same contracts as Chat.
For a support recipe, supply your team name, avatar, and connection status through contentProps. For an AI assistant, pass streaming messages and a run. Markdown remains an optional separate installation. Treat file metadata as untrusted: the host owns upload validation, authorization, size limits, storage, and sanitization.
Unread count is supplied by your app. Clear it when your app considers messages read; minimizing does not infer delivery or read receipts. All default widget labels can be replaced. Localize nested Chat labels through its existing props.
Inherits the nearest DethinkProvider theme, density, and direction through its portal.
Use semantic background, foreground, primary, border, and ring tokens. Width, height, and offset props set --chat-bubble-width, --chat-bubble-height, and --chat-bubble-offset; style can override them. Use non-negative offsets that fit the viewport. Placement is physical: bottom-right stays right in RTL. Mobile bounds account for safe areas and, where available, the visual viewport above the keyboard. Choose zIndex to coexist with your application overlays.
Keyboard and reduced-motion behavior are built in; the surrounding page stays interactive.
Tab to the launcher and press Enter or Space. Opening focuses the dialog; initialFocusRef can choose a child instead. Tab through the chat and back to the page without a focus trap. Escape or Minimize closes it and returns focus when focus was inside. Nested controls can prevent or stop Escape to consume it first. Closing from an external control does not steal focus from that control.
Minimizing dismisses nested ModelPicker/Select, Popover, Dialog, AlertDialog, DropdownMenu, and Tooltip surfaces without resetting the conversation. For other portal-based controls, coordinate their open state with ChatBubble’s controlled open prop.
Closed content is inert and hidden from assistive technology. A custom header must include a close control and label the dialog with aria-label or aria-labelledby. Reduced motion and motion="none" remove spatial transitions. The component renders its portal after hydration.
Acceptance checks: send with Enter, add a newline with Shift+Enter, test IME composition, reject a send and retry without losing text, stop streaming, minimize while a response arrives, and scroll older messages without being pulled to the end. Verify mobile keyboard clearance and screen-reader announcements on your supported devices. Automated coverage includes component behavior, axe, hydration, browser flows, theme/RTL/mobile screenshots, and a clean registry consumer build.
This is an additive component. Existing Chat and ChatMessage consumers require no changes.
To float an existing Chat, move its props into ChatBubble.chat or place it inside ChatBubbleContent. Preserve the same conversationId and app-owned message state. ChatBubble describes the widget; ChatMessage continues to describe an individual message.