Chat Panel

A dockable, floating, minimizable window frame for chat — an inline sidebar that pops out to a draggable, resizable window or collapses to a corner bubble.

1(function ChatPanelPreview() {
2 const RESPONSES = [
3 "Done — I created the task and assigned it to you.",
4 "On it. I'll ping you when the draft is ready to review.",
5 "Good question — the design tokens live in packages/raystack/styles.",
6 "That shipped in the last release; update and it should just work.",
7 "I've noted it down. Anything else you'd like me to pick up?",
8 ];
9
10 const [mode, setMode] = React.useState("docked");
11 const [status, setStatus] = React.useState("idle");
12 const [messages, setMessages] = React.useState([
13 { id: 1, role: "user", text: "create a task in design system 2" },
14 { id: 2, role: "assistant", text: RESPONSES[0] },
15 ]);

Anatomy

1import { ChatPanel } from '@raystack/apsara'
2
3<Flex>
4 <MainContent />
5 <ChatPanel mode={mode} onModeChange={setMode} side="right">
6 <ChatPanel.Header>
7 <ChatPanel.Title>Create task in design system 2</ChatPanel.Title>
8 <ChatPanel.Actions>
9 {/* consumer extras, e.g. ⋯ menu */}
10 <ChatPanel.MinimizeTrigger />
11 <ChatPanel.ExpandTrigger />
12 </ChatPanel.Actions>
13 </ChatPanel.Header>
14 <ChatPanel.Content>{/* Chat.Messages + PromptInput */}</ChatPanel.Content>
15 <ChatPanel.Trigger /> {/* minimized bubble */}
16 </ChatPanel>
17</Flex>

The panel mounts once in your layout and switches between modes in place — no portal, no remount, so chat state (scroll position, focus, streams) survives every transition:

  • docked — an in-flow sidebar (a flex sibling, like SidePanel) that squeezes the main content rather than covering it.
  • floating — the same element switched to position: fixed. Drag it by the header, resize it from any edge or corner.
    • Drag is clamped to the viewport, or to a container via dragBoundary. Turn it off with draggable={false}.
    • Constrain the resize axes with resize. Resizing only shrinks the window below its starting size — pass a larger maxSize to let it grow.
  • minimized — the frame collapses to ChatPanel.Trigger, a fixed corner bubble, and clicking it restores the previous mode. Render no trigger and minimized shows nothing, so your app can supply its own affordance.

There is deliberately no close (×) — header actions are slottable, so apps add their own controls next to the ready-made mode triggers.

Because floating and minimized rely on position: fixed, mount the panel near the layout root: an ancestor with transform, filter or backdrop-filter would break fixed positioning.

Usage

How the panel behaves is set on the root: the way it moves between modes, who owns the current mode, and what a drag or a resize is allowed to do.

Mode transitions

Switching mode moves the panel between in-flow and position: fixed, which no CSS property can tween. So the panel is rendered in its new mode already holding the shape it is leaving, then eased back to rest. transition picks which shape that is.

transitionThe new mode arrives byDuration
minimal (default)fading up out of its own edge or corner--rs-duration-normal
morphtravelling and reshaping out of the box the old mode filled--rs-duration-moderate
1(function ChatPanelTransitions() {
2 const [transition, setTransition] = React.useState("morph");
3 const [mode, setMode] = React.useState("docked");
4
5 return (
6 <Flex direction="column" gap={4} style={{ width: "100%" }}>
7 <Flex gap={7} wrap="wrap">
8 <Flex gap={2}>
9 {["minimal", "morph"].map((type) => (
10 <Button
11 key={type}
12 size="small"
13 color="neutral"
14 variant={transition === type ? "solid" : "outline"}
15 onClick={() => setTransition(type)}

Both ease with --rs-ease-out, so the panel settles into place rather than starting from a standstill. Both are skipped under prefers-reduced-motion: reduce, and neither runs on first paint — a mode has to have changed for there to be a transition.

Dragging is exempt. A drag writes an inline transform, and only the separate scale and translate properties are transitioned, so the panel never lags the pointer and ending a drag never replays the transition.

Controlled mode

Control mode to persist it, or to open the panel from your own UI. The floating window here is fixed to the browser viewport — pop it out and drag its header.

1(function ControlledChatPanel() {
2 const [mode, setMode] = React.useState("docked");
3
4 return (
5 <Flex direction="column" gap={4} style={{ width: "100%" }}>
6 <Flex gap={3}>
7 <Button
8 size="small"
9 variant="outline"
10 color="neutral"
11 onClick={() => setMode("docked")}
12 >
13 Dock
14 </Button>
15 <Button

Constraining the drag to a container

Dragging is built on dnd-kit. By default the floating window is clamped to the viewport; pass an element (or a ref to one) as dragBoundary to confine dragging to it instead. The panel keeps position: fixed, so the boundary only limits where a drag can put it.

1(function BoundedDragChatPanel() {
2 const boundaryRef = React.useRef(null);
3 const [position, setPosition] = React.useState(null);
4 const [open, setOpen] = React.useState(false);
5
6 const handleToggle = () => {
7 if (!open) {
8 // Start the floating window inside the boundary frame.
9 const rect = boundaryRef.current?.getBoundingClientRect();
10 if (rect) setPosition({ x: rect.left + 24, y: rect.top + 24 });
11 }
12 setOpen(!open);
13 };
14
15 return (

Constraining the resize axes

resize mirrors the CSS resize property: both (default), horizontal, vertical or none. Only the handles for the allowed axes render. The default maxSize is the initial floating size, so this demo passes a larger one to allow growing.

1(function ResizeAxesChatPanel() {
2 const [mode, setMode] = React.useState("docked");
3 const [resize, setResize] = React.useState("both");
4
5 return (
6 <Flex direction="column" gap={4} style={{ width: "100%" }}>
7 <Flex gap={3} align="center">
8 <Text size="small" variant="secondary">
9 resize:
10 </Text>
11 {["both", "horizontal", "vertical", "none"].map((value) => (
12 <Button
13 key={value}
14 size="small"
15 variant="outline"

Draggable minimized bubble

The minimized bubble can be dragged like the full panel, so it never ends up covering something the user needs.

1(function DraggableBubbleChatPanel() {
2 const [mode, setMode] = React.useState("docked");
3
4 return (
5 <Flex direction="column" gap={4} style={{ width: "100%" }}>
6 <Text size="small" variant="secondary">
7 The minimized bubble accepts draggable — a short click still restores
8 the panel, and the dropped spot is kept the next time you minimize.
9 </Text>
10 <Flex
11 style={{
12 width: "100%",
13 height: 280,
14 border: "0.5px solid var(--rs-color-border-base-primary)",
15 borderRadius: "var(--rs-radius-4)",

Unread badge on the bubble

Show a count on the bubble while the panel is minimized, so activity is visible without reopening it.

1(function MinimizedWithBadge() {
2 const [mode, setMode] = React.useState("minimized");
3
4 // The transform makes the frame the containing block for the panel's
5 // fixed positioning, so this demo's trigger pins to the frame corner
6 // instead of stacking on the page's other panels.
7 return (
8 <Flex direction="column" gap={4} style={{ width: "100%" }}>
9 <Text size="small" variant="secondary">
10 The minimized trigger is a slot — compose Indicator or Badge for unread
11 counts. Look at the bottom-right of this frame.
12 </Text>
13 <Flex
14 style={{
15 width: "100%",

Persisting placement

Position and size are controllable for apps that restore the floating window across sessions:

1<ChatPanel
2 mode={mode}
3 onModeChange={setMode}
4 position={saved.position}
5 onPositionChange={savePosition}
6 size={saved.size}
7 onSizeChange={saveSize}
8 minSize={{ width: 320, height: 400 }}
9>
10
11</ChatPanel>

API Reference

The frame wraps a header, a content area and the minimized bubble. Header actions are a slot, so an app adds its own controls beside the ready-made mode triggers.

Root

Prop

Type

The title bar. In floating mode it is the drag handle — pointer drags on it move the window (interactive children like buttons are excluded, as is anything marked data-chat-panel-no-drag).

Title

The heading (<h2>), truncated with an ellipsis.

Actions

A slot row at the end of the header for controls.

MinimizeTrigger / ExpandTrigger

Ready-made mode buttons for ChatPanel.Actions, built on IconButton. MinimizeTrigger switches to minimized; ExpandTrigger toggles between docked and floating with a state-aware accessible name. Both render a default icon (minus / size) that children override; ExpandTrigger children also accept a render function receiving { floating } for per-state icons:

1<ChatPanel.ExpandTrigger>
2 {({ floating }) => (floating ? <ShrinkIcon /> : <ExpandIcon />)}
3</ChatPanel.ExpandTrigger>

Content

The body wrapper — a flex column that gives Chat.Messages its bounded height.

Trigger

The minimized-state corner bubble. Children replace the default CoPilotIcon, and draggable lets the bubble be dragged around the viewport — a completed drag never triggers the restore click.

Prop

Type

Slots

Every rendered part carries a stable data-slot attribute for styling and testing:

SlotElement
chat-panelRoot container in every mode
chat-panel-headerHeader bar
chat-panel-titleTitle text in the header
chat-panel-actionsAction group in the header
chat-panel-minimize-triggerButton that collapses the panel to a bubble
chat-panel-expand-triggerButton that restores it
chat-panel-contentPanel body
chat-panel-triggerThe minimized bubble
chat-panel-resize-handleDrag handle on a resizable edge

Accessibility

  • The root renders an <aside> (complementary landmark); give it an aria-label when your page has more than one.
  • ChatPanel.Title is a real <h2> heading.
  • All mode triggers are IconButtons with descriptive, state-aware accessible names ("Minimize chat panel", "Pop out chat panel", "Dock chat panel", "Open chat").
  • Mode transitions never remount the content, so keyboard focus inside the thread or composer is preserved when docking or popping out.
  • Dragging clamps so the header can never leave the viewport (or the dragBoundary container), and the window and a dropped bubble both re-clamp when the browser is resized.
  • Drag and resize are pointer gestures; keep docked mode reachable (the ExpandTrigger toggle) so keyboard users can always use the inline layout.