ChatPanel
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 ];910 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'23<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, likeSidePanel) that squeezes the main content rather than covering it.floating— the same element switched toposition: fixed: drag it by the header (clamped to the viewport, or to a container viadragBoundary; disable withdraggable={false}) and resize it from any edge or corner (constrain the axes withresize). Out of the box resizing can only shrink the window below its initial size — pass a largermaxSizeto let it grow.minimized— the frame collapses toChatPanel.Trigger, a fixed corner bubble; clicking it restores the previous mode. If you don't render a trigger, minimized shows nothing and your app supplies 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.
API Reference
Root
Prop
Type
Header
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 ? <PinRightIcon /> : <SizeIcon />)}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
Mode transitions
Switching mode flips the panel between in-flow and position: fixed, which no
CSS property can tween, so the transform bridges the gap: the panel is rendered
in its new mode already holding the shape it is coming out of, then eased back
to rest. transition picks which shape that is.
minimal (default) brings the new mode in from its own edge or corner and
fades it up: the docked sidebar slides in from the edge it docks to, the
floating window scales and lifts out of its corner, and the bubble grows out of
its own. --rs-duration-normal.
morph measures the box the panel is leaving and draws the new mode back
over it — an inverse translate and scale off its top-left corner — so the
panel travels and reshapes into its new place, the way a shared-layout
animation does. Docking and popping out morph outright. The bubble is a tenth
of the panel's size, so its two pairs cross-fade as well, and the bubble itself
only travels — scaling it up to the panel's box would stretch its icon for the
length of the tween. --rs-duration-moderate, because there is real distance to
cover.
Both use --rs-ease-out, so a transition is off the mark on its first frame and
settles into place rather than easing away from a standstill.
1(function ChatPanelTransitions() {2 const [transition, setTransition] = React.useState("morph");3 const [mode, setMode] = React.useState("docked");45 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 <Button11 key={type}12 size="small"13 color="neutral"14 variant={transition === type ? "solid" : "outline"}15 onClick={() => setTransition(type)}
Both are skipped under prefers-reduced-motion: reduce, and neither runs on
first paint — the transition needs a mode to have changed. Only the individual
transform properties (scale, translate) are transitioned, never transform
itself: dragging writes an inline transform, which stays instant, so a drag
never lags the pointer and ending one never replays the transition.
Examples
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");34 return (5 <Flex direction="column" gap={4} style={{ width: "100%" }}>6 <Flex gap={3}>7 <Button8 size="small"9 variant="outline"10 color="neutral"11 onClick={() => setMode("docked")}12 >13 Dock14 </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);56 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 };1415 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");45 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 <Button13 key={value}14 size="small"15 variant="outline"
Draggable minimized bubble
1(function DraggableBubbleChatPanel() {2 const [mode, setMode] = React.useState("docked");34 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 restores8 the panel, and the dropped spot is kept the next time you minimize.9 </Text>10 <Flex11 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
1(function MinimizedWithBadge() {2 const [mode, setMode] = React.useState("minimized");34 // The transform makes the frame the containing block for the panel's5 // fixed positioning, so this demo's trigger pins to the frame corner6 // 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 unread11 counts. Look at the bottom-right of this frame.12 </Text>13 <Flex14 style={{15 width: "100%",
Persisting placement
Position and size are controllable for apps that restore the floating window across sessions:
1<ChatPanel2 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>
Accessibility
- The root renders an
<aside>(complementarylandmark); give it anaria-labelwhen your page has more than one. ChatPanel.Titleis 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
dragBoundarycontainer), 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.