Skip to content
Agent UI

Pattern demo

A chat widget that floats above the page.

A launcher bubble, a compact panel, and the full ChatShell inside. Close the widget and reopen it — the transcript is preserved because the hook stays mounted.

ACAcme

A mock marketing page

The chat widget floats above the page, keeps its own state, and can be opened from the bubble. Close it, then reopen with the launcher.

Drop-in support widget
Persistent transcript state
Keyboard and screen-reader friendly

Fixed positioning

Anchor the launcher to the bottom-right of the viewport and render it once in the root layout.

Viewport aware

The panel caps its width and height so it stays usable on phones and inside narrow embeds.

Stateful transcript

useChat stays mounted above the panel, so closing and reopening keeps the conversation.

Minimal integration

The whole pattern is a client component with an open flag and one chat hook. Replace the panel body with any composition of primitives.

tsx
"use client";
 
const [open, setOpen] = useState(false);
const chat = useChat({ onSend: reply });
 
return (
<div className="fixed bottom-5 right-5">
{open ? (
<div role="dialog" aria-label="Support chat">
<ChatShell
messages={chat.messages}
isStreaming={chat.isStreaming}
error={chat.error}
onSend={chat.send}
onStop={chat.stop}
/>
</div>
) : null}
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
>
Chat
</button>
</div>
);

Production checklist

  • Render the widget in the root layout, not per page, so state survives navigation.
  • Add a focus trap if the panel behaves as a modal; restore focus to the launcher on close.
  • Persist the transcript if the widget should survive a reload.
  • Cap the panel height with dvh on mobile so the browser chrome never clips the composer.
  • Point onSend at your backend and keep API keys server-side.

Frequently asked questions

How do I add a floating chat widget to an existing site?

Render the launcher and panel once in your root layout, keep the open state in a client component, and mount ChatShell inside the panel. Because the widget is position: fixed, it stays available on every route without remounting the page.

Does the popup trap keyboard focus?

The demo uses dialog semantics, a labelled close button, and a keyboard-reachable launcher. If you present the panel as a modal over the page, add a focus trap and restore focus to the launcher on close; both are app-level decisions rather than styling.

Can the widget keep its conversation when it closes?

Yes. useChat owns the transcript in component state, so keep the hook mounted above the panel or lift the state into a store or context. The demo keeps the hook mounted while the panel is hidden, so reopening preserves the conversation.

Is the popup responsive on mobile?

The panel uses a viewport-capped width and a fixed height, so it fits small screens without touching the edges. On very narrow screens it fills most of the width while keeping the launcher clear.